mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6de842a3 | ||
|
|
2d97d04805 | ||
|
|
55c77021da | ||
|
|
03aedcdd47 | ||
|
|
869eb5a72f | ||
|
|
1a05491be5 | ||
|
|
28bb7e29be | ||
|
|
d6d04b0c56 | ||
|
|
20b2dad610 | ||
|
|
58ca9a9b30 | ||
|
|
dfa9977047 | ||
|
|
4f46e094a1 | ||
|
|
dd914c1479 | ||
|
|
025e7ef1d8 | ||
|
|
b4e837a9fe | ||
|
|
f098f77f4a | ||
|
|
f59ebd4fd5 | ||
|
|
aae97b2bdc |
@@ -203,31 +203,6 @@
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
|
||||
"terminal/terminal-worker-process.ts": [
|
||||
"chmodSync",
|
||||
"closeSync",
|
||||
"existsSync",
|
||||
"openSync",
|
||||
"rmSync",
|
||||
"writeSync"
|
||||
],
|
||||
// Worker startup only: choose and secure the local listening socket before accepting a controller.
|
||||
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
|
||||
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
|
||||
"terminal/terminal-worker-runtime.ts": [
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"statSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
|
||||
"terminal/paseo-cli-path.ts": ["existsSync"],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -10,9 +10,8 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
### Added
|
||||
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -22,10 +21,9 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
### Improved
|
||||
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
@@ -34,7 +32,6 @@ Voice settings now let you configure speech providers and download local models
|
||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -51,17 +48,20 @@ Voice settings now let you configure speech providers and download local models
|
||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### Notes
|
||||
|
||||
- Voice mode now defaults to off; enable it in Voice settings
|
||||
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
|
||||
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### 新增
|
||||
|
||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -21,10 +21,9 @@
|
||||
|
||||
### 改进
|
||||
|
||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
@@ -33,7 +32,6 @@
|
||||
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -50,17 +48,20 @@
|
||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### 说明
|
||||
|
||||
- 语音模式默认关闭,可在语音设置中启用
|
||||
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
|
||||
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
|
||||
|
||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||
|
||||
## Publish
|
||||
|
||||
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||
|
||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(root, "../..");
|
||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||
const output = path.join(root, "output");
|
||||
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||
|
||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||
@@ -116,6 +117,7 @@ const STORES = {
|
||||
},
|
||||
google: {
|
||||
device: "iphone",
|
||||
androidScreenshots: "phoneScreenshots",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
textTop: 150,
|
||||
@@ -134,6 +136,7 @@ const STORES = {
|
||||
},
|
||||
"google-tablet": {
|
||||
device: "ipad",
|
||||
androidScreenshots: "tenInchScreenshots",
|
||||
width: 1800,
|
||||
height: 3200,
|
||||
textTop: 200,
|
||||
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
|
||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||
}
|
||||
|
||||
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||
// which holds links to these renders. Rewriting the links on every render keeps
|
||||
// added, removed and renamed panels in step with the App Store.
|
||||
async function linkAndroidScreenshots(folder, files) {
|
||||
const dir = path.join(androidImages, folder);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const [index, file] of files.entries()) {
|
||||
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPreview(rows) {
|
||||
const tile = 440;
|
||||
const gap = 40;
|
||||
@@ -387,7 +402,9 @@ try {
|
||||
const marks = await loadAgentMarks(work);
|
||||
const rows = [];
|
||||
for (const [name, store] of Object.entries(STORES)) {
|
||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||
const files = await renderStore(browser, name, store, font, marks, work);
|
||||
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||
rows.push(files);
|
||||
}
|
||||
await renderPreview(rows);
|
||||
} finally {
|
||||
|
||||
+4
-1
@@ -215,7 +215,8 @@ fastlane/metadata/android/
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
@@ -223,6 +224,8 @@ fastlane/metadata/android/
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
@@ -66,9 +66,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
|
||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
||||
these dependency trees with tree-shaking disabled to match unbundled production imports.
|
||||
|
||||
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
|
||||
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
|
||||
+19
-29
@@ -99,10 +99,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
|
||||
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
|
||||
checks cover that registry. Unlisted directories do not load.
|
||||
|
||||
The `worktree-include` built-in copies gitignored files selected by
|
||||
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
|
||||
before setup and agents, through `worktree.create.after`.
|
||||
|
||||
Desktop packaging ships the entire built-in plugin directory as an external resource,
|
||||
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
|
||||
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
|
||||
@@ -337,10 +333,11 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup for resources created by plugin code. Server cleanup
|
||||
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
|
||||
not interrupt host teardown. Client installation retention and disposal follow the public
|
||||
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
|
||||
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
|
||||
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
|
||||
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
@@ -385,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
@@ -440,16 +428,21 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
|
||||
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
|
||||
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
|
||||
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
|
||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||
re-derived on each open and following the plugin's lifecycle. See
|
||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||
`plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
||||
executable definitions stay in the plugin process.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
@@ -620,10 +613,7 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. An installation
|
||||
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
|
||||
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
|
||||
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
|
||||
+13
-2
@@ -305,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
@@ -317,6 +317,16 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
### Store screenshots
|
||||
|
||||
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||
|
||||
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
|
||||
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||
|
||||
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
@@ -359,6 +369,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Terminal worker
|
||||
|
||||
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
|
||||
|
||||
## Process model
|
||||
|
||||
```
|
||||
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
|
||||
exits (re-parented to init)
|
||||
```
|
||||
|
||||
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
|
||||
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
|
||||
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
||||
|
||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||
|
||||
## Protocol versions
|
||||
|
||||
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
|
||||
|
||||
| Worker reports | Daemon does |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| its own version | adopts it |
|
||||
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
|
||||
| two or more older | shuts it down; its terminals close (logged) |
|
||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||
|
||||
## Running from its own copy
|
||||
|
||||
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
||||
|
||||
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
|
||||
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
|
||||
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
|
||||
- Dev (`.ts` source) launches from source and copies nothing.
|
||||
|
||||
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
|
||||
|
||||
## Desktop
|
||||
|
||||
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
|
||||
|
||||
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
|
||||
|
||||
## Known limits
|
||||
|
||||
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
|
||||
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
|
||||
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
|
||||
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
|
||||
@@ -0,0 +1,10 @@
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
Added
|
||||
- Let plugins give tools to specific agents
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
Added
|
||||
- Let plugins give tools to specific agents
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
Added
|
||||
- Let plugins give tools to specific agents
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
Added
|
||||
- Let plugins give tools to specific agents
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37590,7 +37590,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39793,12 +39793,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/server": "0.14.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40045,10 +40045,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40060,7 +40060,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40304,7 +40304,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40350,7 +40350,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40379,15 +40379,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40397,8 +40397,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40420,7 +40420,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40447,7 +40447,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40668,16 +40668,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/highlight": "0.14.0",
|
||||
"@getpaseo/plugin": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41598,7 +41598,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43208,7 +43208,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -42,6 +42,20 @@ jobs:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
profile: production
|
||||
|
||||
upload_android_screenshots:
|
||||
name: Upload Google Play screenshots
|
||||
needs: [build_android, submit_android]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Upload screenshots
|
||||
run: |
|
||||
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||
bundle exec fastlane android upload_screenshots
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
openDaemonOverview,
|
||||
restartDaemonInSettings,
|
||||
expectDaemonRestartComplete,
|
||||
expectDaemonRestartFailed,
|
||||
plantUnclosableTerminalProgram,
|
||||
readWorkerPid,
|
||||
openLongLivedTerminal,
|
||||
listTerminalIds,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
@@ -33,57 +29,6 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart keeps a terminal", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
const terminalId = await openLongLivedTerminal(daemon);
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(await listTerminalIds(daemon)).toContain(terminalId);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart and close terminals closes the terminal", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
try {
|
||||
await openLongLivedTerminal(daemon);
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page, { closeTerminals: true });
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
expect(await listTerminalIds(daemon)).toEqual([]);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart and close terminals shows the processes it could not close", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
|
||||
try {
|
||||
program = await plantUnclosableTerminalProgram(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page, { closeTerminals: true });
|
||||
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("restart-cleanup-failed.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
program?.release();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, metroTest as test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
|
||||
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
|
||||
const PUBLISHED_DAEMON_VERSION = "0.8.0";
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
|
||||
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
|
||||
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
publishedVersion: PUBLISHED_DAEMON_VERSION,
|
||||
});
|
||||
|
||||
try {
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.route(/:6767\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
|
||||
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
},
|
||||
{ seededHost: host },
|
||||
);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await run();
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectOutdatedNotice(page: Page): Promise<void> {
|
||||
const callout = page.getByTestId("daemon-outdated-callout").first();
|
||||
await expect(callout).toBeVisible({ timeout: 30_000 });
|
||||
await expect(callout).toContainText("Daemon out of date");
|
||||
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
|
||||
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
|
||||
const update = page.getByTestId("daemon-outdated-callout-update").first();
|
||||
await expect(update).toBeVisible();
|
||||
await expect(update).toBeEnabled();
|
||||
}
|
||||
|
||||
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
|
||||
});
|
||||
|
||||
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await withOutdatedDaemon(page, async () => {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expectOutdatedNotice(page);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,3 @@
|
||||
import {
|
||||
withRetainedPlugin,
|
||||
editRetainedDraft,
|
||||
expectRetainedDraft,
|
||||
expectStoredPluginTitle,
|
||||
hideRetainedPreferences,
|
||||
showRetainedPreferences,
|
||||
expectChangedPlugin,
|
||||
expectRemovedPlugin,
|
||||
} from "../support/helpers/plugin-reconnect";
|
||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -198,51 +188,3 @@ test("plugin content and host labels follow app language without losing an open
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginTranslations(page, info);
|
||||
});
|
||||
|
||||
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withRetainedPlugin(page, async (probe) => {
|
||||
await probe.open();
|
||||
await editRetainedDraft(page, "Connected draft", 0);
|
||||
|
||||
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
|
||||
await probe.disconnect();
|
||||
await expectRetainedDraft(page, "Connected draft", 1);
|
||||
await editRetainedDraft(page, "Offline draft", 1);
|
||||
await probe.changeStoredTitle("Saved by another client");
|
||||
await expectStoredPluginTitle(page, "Agent monitor");
|
||||
await probe.reconnect();
|
||||
await expectStoredPluginTitle(page, "Saved by another client");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("inactive settings refresh without replacing the installation", async () => {
|
||||
await hideRetainedPreferences(page);
|
||||
await probe.disconnect();
|
||||
await probe.changeStoredTitle("Saved while preferences hidden");
|
||||
await probe.reconnect();
|
||||
await showRetainedPreferences(page);
|
||||
await expectStoredPluginTitle(page, "Saved while preferences hidden");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("changed code replaces the retained installation after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.changeCode();
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
await probe.reconnect();
|
||||
await expectChangedPlugin(page);
|
||||
await editRetainedDraft(page, "New code draft", 0);
|
||||
});
|
||||
|
||||
await test.step("removal is reconciled after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.remove();
|
||||
await expectRetainedDraft(page, "New code draft", 1);
|
||||
await probe.reconnect();
|
||||
await expectRemovedPlugin(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
@@ -7,8 +6,6 @@ import {
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
await reconnectPluginChat(page, gate);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -415,19 +415,8 @@ test.describe("Settings — compact master-detail", () => {
|
||||
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
|
||||
|
||||
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
|
||||
|
||||
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openWorkspace(page, workspace);
|
||||
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
});
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
// The compact sidebar shows only native callouts, so the prompt stays out.
|
||||
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
|
||||
await expect(worktreeSetupCallout).toHaveCount(0);
|
||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Native daemon-outdated notice
|
||||
|
||||
The flow needs the development app connected to an isolated daemon older than the app. Install a published daemon that predates `terminalsSurviveRestart`, start it in its own home, and bundle the app against it. `<checkout>` is the root of this repository; `<port>`, `<metro-port>` and `<udid>` are a free daemon port, a free Metro port and the booted simulator.
|
||||
|
||||
Run this once, in any directory. It installs into `/tmp/old-daemon`, not globally:
|
||||
|
||||
```sh
|
||||
npm install --prefix /tmp/old-daemon --no-package-lock @getpaseo/server@0.8.0
|
||||
```
|
||||
|
||||
Terminal 1, the old daemon. It runs in the foreground; keep it running:
|
||||
|
||||
```sh
|
||||
cd /tmp/old-daemon/node_modules/@getpaseo/server
|
||||
PASEO_HOME=/tmp/old-daemon-home PASEO_LISTEN=0.0.0.0:<port> PASEO_RELAY_ENABLED=0 node dist/scripts/supervisor-entrypoint.js
|
||||
```
|
||||
|
||||
Terminal 2, Metro. It runs in the foreground; keep it running:
|
||||
|
||||
```sh
|
||||
cd <checkout>/packages/app
|
||||
APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON=localhost:<port> npx expo start --port <metro-port> --clear
|
||||
```
|
||||
|
||||
Terminal 3, the flow, while terminals 1 and 2 are still running:
|
||||
|
||||
```sh
|
||||
cd <checkout>
|
||||
xcrun simctl openurl <udid> "paseo://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A<metro-port>"
|
||||
maestro --udid <udid> test packages/app/e2e/mobile/agent-device/native-daemon-outdated-notice.ios.yaml --env DAEMON_VERSION=0.8.0
|
||||
```
|
||||
|
||||
Start with the sidebar closed. The flow opens it, then asserts the notice, its daemon version, the terminals line for a daemon without `terminalsSurviveRestart`, and an enabled Update button. Do not tap Update: on 0.8.0 it runs a global `npm install -g @getpaseo/cli@latest`.
|
||||
|
||||
When you are done, stop terminals 1 and 2 and remove `/tmp/old-daemon` and `/tmp/old-daemon-home`.
|
||||
@@ -1,26 +0,0 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
# The app must be connected to a daemon older than the app that predates
|
||||
# terminalsSurviveRestart (0.8.0). See native-daemon-outdated-notice-preconditions.md.
|
||||
- launchApp
|
||||
- runFlow:
|
||||
when:
|
||||
visible: ".*getRegistrationInfoAsync.*"
|
||||
commands:
|
||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
||||
- tapOn: Dismiss
|
||||
- extendedWaitUntil:
|
||||
visible: "Open menu"
|
||||
timeout: 60000
|
||||
- tapOn: "Open menu"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: daemon-outdated-callout
|
||||
timeout: 30000
|
||||
- assertVisible: "Daemon out of date"
|
||||
- assertVisible: ".*daemon v${DAEMON_VERSION}.*"
|
||||
- assertVisible: "Updating restarts the daemon and closes open terminals."
|
||||
- assertVisible:
|
||||
id: daemon-outdated-callout-update
|
||||
enabled: true
|
||||
- takeScreenshot: native-daemon-outdated-notice
|
||||
@@ -1,7 +1,3 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -19,27 +15,14 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
}
|
||||
|
||||
export async function restartDaemonInSettings(
|
||||
page: Page,
|
||||
options: { closeTerminals?: boolean } = {},
|
||||
) {
|
||||
const button = page.getByRole("button", {
|
||||
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
|
||||
exact: true,
|
||||
});
|
||||
export async function restartDaemonInSettings(page: Page) {
|
||||
const button = page.getByRole("button", { name: "Restart", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await button.click();
|
||||
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectDaemonRestartFailed(page: Page, text: string) {
|
||||
const error = page.getByTestId("host-page-restart-error");
|
||||
await expect(error).toBeVisible({ timeout: 30_000 });
|
||||
await expect(error).toContainText(text);
|
||||
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function expectDaemonRestartComplete(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
@@ -57,100 +40,3 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
|
||||
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: daemon.port,
|
||||
clientIdPrefix: "lifecycle-terminal",
|
||||
});
|
||||
try {
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: daemon.paseoHome },
|
||||
title: "lifecycle",
|
||||
});
|
||||
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
|
||||
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
|
||||
workspaceId: workspace.workspace.id,
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1e9)"],
|
||||
});
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
return created.terminal.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: daemon.port,
|
||||
clientIdPrefix: "lifecycle-terminals",
|
||||
});
|
||||
try {
|
||||
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export interface UnclosableTerminalProgram {
|
||||
pid: number;
|
||||
/** Ends the program's parent, which lets the system reap the program. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
|
||||
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
|
||||
* it. A kill cleanup can only report it as still running. POSIX only.
|
||||
*/
|
||||
export async function plantUnclosableTerminalProgram(
|
||||
daemon: IsolatedHostDaemon,
|
||||
): Promise<UnclosableTerminalProgram> {
|
||||
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
parent.unref();
|
||||
const release = () => {
|
||||
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
|
||||
};
|
||||
try {
|
||||
const pid = await new Promise<number>((resolve) => {
|
||||
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
|
||||
});
|
||||
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
|
||||
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
|
||||
await new Promise((resolve) => gone.once("exit", resolve));
|
||||
const record = {
|
||||
recordId: "planted-record",
|
||||
pid: gone.pid,
|
||||
bootId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
|
||||
protocolVersion: 1,
|
||||
paseoVersion: "0.0.0-test",
|
||||
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
|
||||
secret: "planted-secret",
|
||||
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { pid, release };
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { z } from "zod";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
const PLUGIN_ID = "retained-plugin-e2e";
|
||||
|
||||
export function retainedPluginSource(version: string) {
|
||||
return `import { useState } from "react";
|
||||
import { Text, View, TextInput, Pressable } from "react-native";
|
||||
import { useSettings } from "@getpaseo/plugin/client";
|
||||
import { preferences } from "./shared/preferences";
|
||||
function Preferences({ color }) {
|
||||
const settings = useSettings(preferences);
|
||||
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
|
||||
}
|
||||
function Surface({ theme }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [clicks, setClicks] = useState(0);
|
||||
const [showPreferences, setShowPreferences] = useState(true);
|
||||
const color = theme.colors.foreground;
|
||||
return <View>
|
||||
<Text style={{ color }}>Plugin version ${version}</Text>
|
||||
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
|
||||
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
|
||||
{showPreferences ? <Preferences color={color} /> : null}
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function withRetainedPlugin(
|
||||
page: Page,
|
||||
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
|
||||
) {
|
||||
const probe = await createProbe(page);
|
||||
try {
|
||||
await run(probe);
|
||||
} finally {
|
||||
await probe.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function createProbe(page: Page) {
|
||||
const example = await copyPluginExample("settings");
|
||||
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
|
||||
const previous = await peer.getDaemonConfig();
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const clientEntry = path.join(example.directory, "index.client.tsx");
|
||||
let catalogCount = 0;
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(example.directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(clientEntry, retainedPluginSource("one"));
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await peer.installDirectoryPlugin(example.directory);
|
||||
} catch (error) {
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
async open() {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
|
||||
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
|
||||
},
|
||||
async disconnect() {
|
||||
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
},
|
||||
async reconnect() {
|
||||
gate.restore();
|
||||
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
|
||||
},
|
||||
async changeStoredTitle(title: string) {
|
||||
const rpc = settingsRpc("display");
|
||||
const current = rpc.read.output.parse(
|
||||
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
|
||||
);
|
||||
if (current.status !== "ready") throw new Error("Expected ready settings");
|
||||
const values = z
|
||||
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
|
||||
.parse(current.values);
|
||||
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
|
||||
revision: current.revision,
|
||||
values: { ...values, title },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "saved", values: { title } });
|
||||
},
|
||||
async changeCode() {
|
||||
await writeFile(clientEntry, retainedPluginSource("two"));
|
||||
await peer.reloadPlugin(PLUGIN_ID);
|
||||
},
|
||||
async remove() {
|
||||
await peer.removePlugin(PLUGIN_ID);
|
||||
},
|
||||
async close() {
|
||||
gate.restore();
|
||||
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
|
||||
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
|
||||
await expectRetainedDraft(page, draft, clicks + 1);
|
||||
}
|
||||
|
||||
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectStoredPluginTitle(page: Page, title: string) {
|
||||
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function hideRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
|
||||
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectChangedPlugin(page: Page) {
|
||||
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
|
||||
await expectRetainedDraft(page, "", 0);
|
||||
}
|
||||
|
||||
export async function expectRemovedPlugin(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
|
||||
}
|
||||
@@ -5,64 +5,8 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||
export function trackPluginChatRecovery(page: Page) {
|
||||
const messages: {
|
||||
type: string;
|
||||
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||
}[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.type === "session") messages.push(frame.message);
|
||||
};
|
||||
socket.on("framesent", collect);
|
||||
socket.on("framereceived", collect);
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
messages.length = 0;
|
||||
},
|
||||
async expectCatalogWithoutRetransmission() {
|
||||
await expect
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "plugin.catalog.get.response" &&
|
||||
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const entry = messages
|
||||
.find((message) => message.type === "plugin.catalog.get.response")
|
||||
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("clientBundle");
|
||||
},
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconnectPluginChat(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
gate.restore();
|
||||
}
|
||||
export const ASSISTANT_TEXT =
|
||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "sh.paseo"
|
||||
|
||||
# Store screenshots are rendered into assets/store-screenshots/output; see the
|
||||
# README there. Google Play and F-Droid both read fastlane/metadata/android at
|
||||
# the repo root, whose screenshot files link to those renders.
|
||||
REPO_ROOT = File.expand_path("../../..", __dir__)
|
||||
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
|
||||
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
|
||||
|
||||
# deliver reads screenshots from one folder per language and picks the device
|
||||
# slot from each image's size, so both Apple sets go into en-US together.
|
||||
def stage_app_store_screenshots
|
||||
root = Dir.mktmpdir("app-store-screenshots")
|
||||
locale = File.join(root, "en-US")
|
||||
FileUtils.mkdir_p(locale)
|
||||
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
|
||||
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
|
||||
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
|
||||
end
|
||||
end
|
||||
root
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Submit the latest TestFlight build to App Store Review"
|
||||
lane :submit_review do
|
||||
@@ -87,7 +111,8 @@ platform :ios do
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
screenshots_path: stage_app_store_screenshots,
|
||||
overwrite_screenshots: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
@@ -96,3 +121,25 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
platform :android do
|
||||
desc "Replace the Google Play store screenshots with the ones in the repo"
|
||||
lane :upload_screenshots do
|
||||
# supply attaches listing changes to a release on the production track.
|
||||
# The release workflow passes the version code it just submitted; without
|
||||
# it, supply uses the track's only release.
|
||||
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
|
||||
supply(
|
||||
package_name: APP_IDENTIFIER,
|
||||
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||
metadata_path: ANDROID_METADATA,
|
||||
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
sync_image_upload: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -84,7 +84,6 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { DaemonOutdatedCalloutSource } from "@/components/daemon-outdated-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
@@ -487,7 +486,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<DaemonOutdatedCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type SidebarCalloutAction,
|
||||
SidebarCalloutDescriptionText,
|
||||
} from "@/components/sidebar-callout";
|
||||
import {
|
||||
type DaemonOutdatedUpdateState,
|
||||
resolveDaemonOutdatedCallout,
|
||||
} from "@/components/resolve-daemon-outdated-callout";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { updateDaemonFromSettings } from "@/screens/settings/daemon-lifecycle";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { orderHostsLocalFirst, resolveActiveHostServerId } from "@/types/host-connection";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface HostUpdateState {
|
||||
serverId: string;
|
||||
update: DaemonOutdatedUpdateState;
|
||||
}
|
||||
|
||||
const IDLE: DaemonOutdatedUpdateState = { status: "idle" };
|
||||
|
||||
function DaemonOutdatedDescription({ lines }: { lines: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{lines.map((line) => (
|
||||
<SidebarCalloutDescriptionText key={line}>{line}</SidebarCalloutDescriptionText>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmAndUpdateDaemonInput {
|
||||
serverId: string;
|
||||
hostLabel: string;
|
||||
client: DaemonClient;
|
||||
t: TFunction;
|
||||
onState: (next: DaemonOutdatedUpdateState) => void;
|
||||
}
|
||||
|
||||
async function confirmAndUpdateDaemon(input: ConfirmAndUpdateDaemonInput): Promise<void> {
|
||||
const { serverId, hostLabel, client, t, onState } = input;
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirmDialog({
|
||||
title: t("settings.host.daemon.update.confirmTitle", { name: hostLabel }),
|
||||
message: t("settings.host.daemon.update.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.update.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DaemonOutdatedCallout] Failed to open update confirmation", error);
|
||||
onState({ status: "failed", message: t("settings.host.daemon.update.dialogFailedMessage") });
|
||||
return;
|
||||
}
|
||||
if (!confirmed) return;
|
||||
|
||||
const phaseLabels = {
|
||||
starting: t("settings.host.daemon.update.phaseStarting"),
|
||||
downloading: t("settings.host.daemon.update.phaseDownloading"),
|
||||
installing: t("settings.host.daemon.update.phaseInstalling"),
|
||||
complete: t("settings.host.daemon.update.phaseComplete"),
|
||||
};
|
||||
onState({ status: "updating", phase: phaseLabels.starting });
|
||||
const requestId = `sidebar_daemon_update_${serverId}`;
|
||||
const unsubscribe = client.on("daemon.update.progress", (message) => {
|
||||
if (message.payload.requestId !== requestId) return;
|
||||
onState({ status: "updating", phase: phaseLabels[message.payload.phase] });
|
||||
});
|
||||
try {
|
||||
await updateDaemonFromSettings(serverId, {
|
||||
updateDaemon: () => client.updateDaemon(requestId),
|
||||
getStatus: async () => ({
|
||||
...(await client.getDaemonStatus()),
|
||||
serverId: client.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: client.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
});
|
||||
onState({ status: "idle" });
|
||||
} catch (error) {
|
||||
console.error(`[DaemonOutdatedCallout] Failed to update daemon ${hostLabel}`, error);
|
||||
onState({
|
||||
status: "failed",
|
||||
message: t("settings.host.daemon.update.requestFailedMessage", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sidebar notice when the active host's daemon is older than this app. */
|
||||
export function DaemonOutdatedCalloutSource() {
|
||||
const { t } = useTranslation();
|
||||
const callouts = useSidebarCallouts();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||
const orderedHosts = useMemo(
|
||||
() => orderHostsLocalFirst(hosts, localServerId),
|
||||
[hosts, localServerId],
|
||||
);
|
||||
// Same host resolution as settings: the last workspace's host, else the local daemon, else the first.
|
||||
const serverId = resolveActiveHostServerId({
|
||||
selectedServerId: lastWorkspaceSelection?.serverId ?? null,
|
||||
localServerId,
|
||||
hosts,
|
||||
orderedHosts,
|
||||
});
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? "";
|
||||
const daemonClient = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const serverInfo = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.serverInfo ?? null) : null,
|
||||
);
|
||||
const [hostUpdate, setHostUpdate] = useState<HostUpdateState | null>(null);
|
||||
const update = hostUpdate && hostUpdate.serverId === serverId ? hostUpdate.update : IDLE;
|
||||
|
||||
const runUpdate = useStableEvent(() => {
|
||||
if (!serverId || !daemonClient) return;
|
||||
void confirmAndUpdateDaemon({
|
||||
serverId,
|
||||
hostLabel,
|
||||
client: daemonClient,
|
||||
t,
|
||||
onState: (next) => setHostUpdate({ serverId, update: next }),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !serverInfo) return;
|
||||
const descriptor = resolveDaemonOutdatedCallout({
|
||||
serverId,
|
||||
hostLabel,
|
||||
appVersion: resolveAppVersion(),
|
||||
daemonVersion: serverInfo.version,
|
||||
supportsSelfUpdate: serverInfo.features?.daemonSelfUpdate === true,
|
||||
desktopManaged: serverInfo.desktopManaged === true,
|
||||
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
|
||||
terminalsSurviveRestart: serverInfo.features?.terminalsSurviveRestart === true,
|
||||
isConnected,
|
||||
update,
|
||||
});
|
||||
if (!descriptor) return;
|
||||
|
||||
const actions: SidebarCalloutAction[] = descriptor.updateAction
|
||||
? [
|
||||
{
|
||||
label: descriptor.updateAction.label,
|
||||
onPress: runUpdate,
|
||||
variant: "primary",
|
||||
disabled: descriptor.updateAction.disabled,
|
||||
testID: "daemon-outdated-callout-update",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return callouts.show({
|
||||
id: descriptor.id,
|
||||
dismissalKey: descriptor.dismissalKey,
|
||||
priority: descriptor.priority,
|
||||
title: descriptor.title,
|
||||
description: <DaemonOutdatedDescription lines={descriptor.description} />,
|
||||
variant: descriptor.variant,
|
||||
actions,
|
||||
testID: descriptor.testID,
|
||||
native: true,
|
||||
});
|
||||
// t: the resolver reads the active language through i18n, so a language change rebuilds the copy.
|
||||
}, [callouts, hostLabel, isConnected, runUpdate, serverId, serverInfo, t, update]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -40,7 +40,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -606,8 +605,6 @@ function MobileSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot nativeOnly />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
@@ -787,7 +784,7 @@ function DesktopSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot nativeOnly={isNative} />
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
resolveDaemonOutdatedCallout,
|
||||
type ResolveDaemonOutdatedCalloutInput,
|
||||
} from "./resolve-daemon-outdated-callout";
|
||||
|
||||
function input(
|
||||
overrides: Partial<ResolveDaemonOutdatedCalloutInput> = {},
|
||||
): ResolveDaemonOutdatedCalloutInput {
|
||||
return {
|
||||
serverId: "srv_alpha",
|
||||
hostLabel: "Workstation",
|
||||
appVersion: "0.12.0",
|
||||
daemonVersion: "0.11.4",
|
||||
supportsSelfUpdate: true,
|
||||
desktopManaged: false,
|
||||
terminalsSurviveRestart: true,
|
||||
isConnected: true,
|
||||
update: { status: "idle" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDaemonOutdatedCallout", () => {
|
||||
it("shows an update notice when the daemon is older than the app", () => {
|
||||
expect(resolveDaemonOutdatedCallout(input())).toEqual({
|
||||
id: "daemon-outdated",
|
||||
dismissalKey: "daemon-outdated:srv_alpha:0.11.4",
|
||||
priority: 250,
|
||||
title: "Daemon out of date",
|
||||
description: [
|
||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
||||
"Updating restarts the daemon. Terminals stay open.",
|
||||
],
|
||||
variant: "default",
|
||||
updateAction: { label: "Update", disabled: false },
|
||||
testID: "daemon-outdated-callout",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides when the daemon matches or is newer than the app", () => {
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.0" }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.12.0" }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.1" }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "1.0.0" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("compares numeric components numerically, not as strings", () => {
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.10.0", daemonVersion: "0.9.9" })),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.9.9", daemonVersion: "0.10.0" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders prereleases before their release", () => {
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0", daemonVersion: "0.12.0-beta.1" })),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-beta.1", daemonVersion: "0.12.0" })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(
|
||||
input({ appVersion: "0.12.0-beta.10", daemonVersion: "0.12.0-beta.2" }),
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(
|
||||
input({ appVersion: "0.12.0-beta.2", daemonVersion: "0.12.0-beta.2" }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-rc.1", daemonVersion: "0.11.9" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides when either version is missing or unparseable", () => {
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: null }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: null }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "dev" }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11" }))).toBeNull();
|
||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: "unknown" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no button for a desktop-managed daemon and points to Paseo on that computer", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(input({ desktopManaged: true }));
|
||||
|
||||
expect(descriptor?.updateAction).toBeNull();
|
||||
expect(descriptor?.description).toEqual([
|
||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
||||
"Paseo Desktop manages this daemon. Update Paseo on that computer.",
|
||||
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never promises terminals survive a desktop update, which closes them on Windows", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(
|
||||
input({ desktopManaged: true, terminalsSurviveRestart: true }),
|
||||
);
|
||||
|
||||
expect(descriptor?.description).not.toContain(
|
||||
"Updating restarts the daemon. Terminals stay open.",
|
||||
);
|
||||
expect(descriptor?.description.at(-1)).toContain("closes them on Windows");
|
||||
});
|
||||
|
||||
it("says a desktop update closes terminals when the daemon does not keep them", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(
|
||||
input({ desktopManaged: true, terminalsSurviveRestart: false }),
|
||||
);
|
||||
|
||||
expect(descriptor?.description.at(-1)).toBe(
|
||||
"Updating restarts the daemon and closes open terminals.",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no button when the daemon does not support self-update", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(input({ supportsSelfUpdate: false }));
|
||||
|
||||
expect(descriptor?.updateAction).toBeNull();
|
||||
expect(descriptor?.description).toContain(
|
||||
"This daemon can't update itself. Update Paseo on that computer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says the update closes terminals when the daemon does not keep them", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
|
||||
|
||||
expect(descriptor?.description).toEqual([
|
||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
||||
"Updating restarts the daemon and closes open terminals.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables the button while the host is disconnected", () => {
|
||||
expect(resolveDaemonOutdatedCallout(input({ isConnected: false }))?.updateAction).toEqual({
|
||||
label: "Update",
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the update phase on a disabled button while updating", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(
|
||||
input({ update: { status: "updating", phase: "Downloading packages..." } }),
|
||||
);
|
||||
|
||||
expect(descriptor?.variant).toBe("default");
|
||||
expect(descriptor?.updateAction).toEqual({ label: "Downloading packages...", disabled: true });
|
||||
});
|
||||
|
||||
it("surfaces a failed update as an error with the button available again", () => {
|
||||
const descriptor = resolveDaemonOutdatedCallout(
|
||||
input({ update: { status: "failed", message: "Failed to update the daemon: boom" } }),
|
||||
);
|
||||
|
||||
expect(descriptor?.title).toBe("Daemon update failed");
|
||||
expect(descriptor?.variant).toBe("error");
|
||||
expect(descriptor?.description).toEqual(["Failed to update the daemon: boom"]);
|
||||
expect(descriptor?.updateAction).toEqual({ label: "Update", disabled: false });
|
||||
});
|
||||
|
||||
it("keys dismissal by host and daemon version", () => {
|
||||
const key = resolveDaemonOutdatedCallout(input())?.dismissalKey;
|
||||
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.11.4" }))?.dismissalKey).toBe(
|
||||
key,
|
||||
);
|
||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: "0.13.0" }))?.dismissalKey).toBe(key);
|
||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11.5" }))?.dismissalKey).toBe(
|
||||
"daemon-outdated:srv_alpha:0.11.5",
|
||||
);
|
||||
expect(resolveDaemonOutdatedCallout(input({ serverId: "srv_beta" }))?.dismissalKey).toBe(
|
||||
"daemon-outdated:srv_beta:0.11.4",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the active app language", async () => {
|
||||
await i18n.changeLanguage("es");
|
||||
try {
|
||||
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
|
||||
|
||||
expect(descriptor?.title).toBe("Daemon desactualizado");
|
||||
expect(descriptor?.description).toEqual([
|
||||
"Workstation ejecuta el daemon v0.11.4. Esta app es v0.12.0.",
|
||||
"Actualizar reinicia el daemon y cierra las terminales abiertas.",
|
||||
]);
|
||||
expect(descriptor?.updateAction?.label).toBe("Actualizar");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DaemonOutdatedUpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "updating"; phase: string }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface ResolveDaemonOutdatedCalloutInput {
|
||||
serverId: string;
|
||||
hostLabel: string;
|
||||
appVersion: string | null;
|
||||
daemonVersion: string | null;
|
||||
supportsSelfUpdate: boolean;
|
||||
desktopManaged: boolean;
|
||||
terminalsSurviveRestart: boolean;
|
||||
isConnected: boolean;
|
||||
update: DaemonOutdatedUpdateState;
|
||||
}
|
||||
|
||||
export interface DaemonOutdatedUpdateAction {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonOutdatedCalloutDescriptor {
|
||||
id: "daemon-outdated";
|
||||
dismissalKey: string;
|
||||
priority: number;
|
||||
title: string;
|
||||
description: string[];
|
||||
variant: "default" | "error";
|
||||
updateAction: DaemonOutdatedUpdateAction | null;
|
||||
testID: "daemon-outdated-callout";
|
||||
}
|
||||
|
||||
interface ParsedVersion {
|
||||
core: [number, number, number];
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
const VERSION_PATTERN =
|
||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
|
||||
const NUMERIC_IDENTIFIER = /^\d+$/;
|
||||
|
||||
function parseVersion(value: string | null): ParsedVersion | null {
|
||||
const match = value?.trim().match(VERSION_PATTERN);
|
||||
if (!match) return null;
|
||||
return {
|
||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
prerelease: match[4] ? match[4].split(".") : [],
|
||||
};
|
||||
}
|
||||
|
||||
function compareIdentifiers(left: string, right: string): number {
|
||||
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
|
||||
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
|
||||
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
|
||||
if (isLeftNumeric) return -1;
|
||||
if (isRightNumeric) return 1;
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
|
||||
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = left.core[index] - right.core[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
||||
return right.prerelease.length - left.prerelease.length;
|
||||
}
|
||||
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return left.prerelease.length - right.prerelease.length;
|
||||
}
|
||||
|
||||
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
|
||||
if (input.update.status === "updating") {
|
||||
return { label: input.update.phase, disabled: true };
|
||||
}
|
||||
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
|
||||
}
|
||||
|
||||
function formatVersion(version: ParsedVersion): string {
|
||||
const core = version.core.join(".");
|
||||
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
|
||||
}
|
||||
|
||||
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
|
||||
// on Windows, which closes them. server_info does not name the host's platform, so the line says
|
||||
// both.
|
||||
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
|
||||
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
|
||||
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
|
||||
return i18n.t("daemonOutdated.terminalsStayOpen");
|
||||
}
|
||||
|
||||
function resolveDescription(
|
||||
input: ResolveDaemonOutdatedCalloutInput,
|
||||
versions: { daemon: ParsedVersion; app: ParsedVersion },
|
||||
): string[] {
|
||||
if (input.update.status === "failed") return [input.update.message];
|
||||
|
||||
const lines = [
|
||||
i18n.t("daemonOutdated.versions", {
|
||||
host: input.hostLabel,
|
||||
daemonVersion: `v${formatVersion(versions.daemon)}`,
|
||||
appVersion: `v${formatVersion(versions.app)}`,
|
||||
}),
|
||||
];
|
||||
if (input.desktopManaged) {
|
||||
lines.push(i18n.t("daemonOutdated.desktopManaged"));
|
||||
} else if (!input.supportsSelfUpdate) {
|
||||
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
|
||||
}
|
||||
lines.push(resolveTerminalsLine(input));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function resolveDaemonOutdatedCallout(
|
||||
input: ResolveDaemonOutdatedCalloutInput,
|
||||
): DaemonOutdatedCalloutDescriptor | null {
|
||||
const app = parseVersion(input.appVersion);
|
||||
const daemon = parseVersion(input.daemonVersion);
|
||||
if (!app || !daemon) return null;
|
||||
if (compareVersions(daemon, app) >= 0) return null;
|
||||
|
||||
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
|
||||
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
|
||||
const isFailed = input.update.status === "failed";
|
||||
|
||||
return {
|
||||
id: "daemon-outdated",
|
||||
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
|
||||
priority: 250,
|
||||
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
|
||||
description: resolveDescription(input, { daemon, app }),
|
||||
variant: isFailed ? "error" : "default",
|
||||
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
|
||||
testID: "daemon-outdated-callout",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sidebar-callout-slot.native";
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SidebarCalloutSlot() {
|
||||
return null;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<RegisterCallout />
|
||||
<SidebarCalloutSlot nativeOnly={false} />
|
||||
<SidebarCalloutSlot />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<SidebarCalloutSlot nativeOnly={false} />
|
||||
<SidebarCalloutSlot />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
+2
-6
@@ -2,14 +2,10 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
|
||||
|
||||
/**
|
||||
* `nativeOnly` limits the slot to callouts marked `native`. Native apps and every compact
|
||||
* sidebar pass it: the other callouts are about the desktop app.
|
||||
*/
|
||||
export function SidebarCalloutSlot({ nativeOnly }: { nativeOnly: boolean }) {
|
||||
export function SidebarCalloutSlot() {
|
||||
return (
|
||||
<View style={styles.slot} collapsable={false}>
|
||||
<SidebarCalloutViewport nativeOnly={nativeOnly} />
|
||||
<SidebarCalloutViewport />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
showSidebarCallout,
|
||||
type SidebarCalloutEntry,
|
||||
type SidebarCalloutOptions,
|
||||
type SidebarCalloutSelectionState,
|
||||
type SidebarCalloutState,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
@@ -38,7 +37,7 @@ export interface SidebarCalloutsApi {
|
||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||
|
||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||
|
||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||
void AsyncStorage.setItem(
|
||||
@@ -114,10 +113,11 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||
|
||||
return (
|
||||
<SidebarCalloutApiContext.Provider value={api}>
|
||||
<SidebarCalloutStateContext.Provider value={state}>
|
||||
<SidebarCalloutStateContext.Provider value={activeCallout}>
|
||||
{children}
|
||||
</SidebarCalloutStateContext.Provider>
|
||||
</SidebarCalloutApiContext.Provider>
|
||||
@@ -132,20 +132,12 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
|
||||
return api;
|
||||
}
|
||||
|
||||
export function useActiveSidebarCallout(
|
||||
options: { nativeOnly?: boolean } = {},
|
||||
): SidebarCalloutEntry | null {
|
||||
const state = useContext(SidebarCalloutStateContext);
|
||||
const nativeOnly = options.nativeOnly === true;
|
||||
return useMemo(
|
||||
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
|
||||
[nativeOnly, state],
|
||||
);
|
||||
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
|
||||
return useContext(SidebarCalloutStateContext);
|
||||
}
|
||||
|
||||
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */
|
||||
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) {
|
||||
const activeCallout = useActiveSidebarCallout({ nativeOnly });
|
||||
export function SidebarCalloutViewport() {
|
||||
const activeCallout = useActiveSidebarCallout();
|
||||
const api = useSidebarCallouts();
|
||||
if (!activeCallout) {
|
||||
return null;
|
||||
|
||||
@@ -31,30 +31,6 @@ describe("sidebar callout state", () => {
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
||||
});
|
||||
|
||||
it("selects only callouts marked native for the native sidebar", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "desktop-update",
|
||||
priority: 300,
|
||||
title: "Update available",
|
||||
}).state;
|
||||
state = showSidebarCallout(state, {
|
||||
id: "daemon-outdated",
|
||||
priority: 250,
|
||||
title: "Daemon out of date",
|
||||
native: true,
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||
expect(selectActiveSidebarCallout(state, { nativeOnly: true })?.title).toBe(
|
||||
"Daemon out of date",
|
||||
);
|
||||
|
||||
state = dismissSidebarCallout(state, "daemon-outdated").state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state, { nativeOnly: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces a callout by id without duplicating the queue item", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
|
||||
@@ -14,8 +14,6 @@ export interface SidebarCalloutOptions {
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
/** Also shown in the iOS and Android sidebar and every compact sidebar. Callouts about the desktop app leave it unset. */
|
||||
native?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
||||
@@ -131,19 +129,10 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
|
||||
return { ...state, callouts: [] };
|
||||
}
|
||||
|
||||
export type SidebarCalloutSelectionState = Pick<
|
||||
SidebarCalloutState,
|
||||
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
|
||||
>;
|
||||
|
||||
export function selectActiveSidebarCallout(
|
||||
state: SidebarCalloutSelectionState,
|
||||
options: { nativeOnly?: boolean } = {},
|
||||
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||
): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = state.callouts.filter((entry) => {
|
||||
if (options.nativeOnly && entry.native !== true) {
|
||||
return false;
|
||||
}
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
|
||||
@@ -2058,18 +2058,6 @@ export const ar: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "رفض",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "الخدمة قديمة",
|
||||
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
|
||||
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
|
||||
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
|
||||
desktopUpdateTerminals:
|
||||
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
|
||||
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
|
||||
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
|
||||
update: "تحديث",
|
||||
updateFailedTitle: "فشل تحديث الخدمة",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "لا توجد بيانات للسياق",
|
||||
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
|
||||
@@ -2885,10 +2873,6 @@ export const ar: TranslationResources = {
|
||||
requestFailedTitle: "خطأ",
|
||||
requestFailedMessage:
|
||||
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
||||
closeTerminals: "إعادة التشغيل وإغلاق الطرفيات",
|
||||
closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.",
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2083,18 +2083,6 @@ export const en = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Dismiss",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Daemon out of date",
|
||||
versions: "{{host}} runs daemon {{daemonVersion}}. This app is {{appVersion}}.",
|
||||
terminalsStayOpen: "Updating restarts the daemon. Terminals stay open.",
|
||||
terminalsClose: "Updating restarts the daemon and closes open terminals.",
|
||||
desktopUpdateTerminals:
|
||||
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
|
||||
desktopManaged: "Paseo Desktop manages this daemon. Update Paseo on that computer.",
|
||||
selfUpdateUnsupported: "This daemon can't update itself. Update Paseo on that computer.",
|
||||
update: "Update",
|
||||
updateFailedTitle: "Daemon update failed",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "No context data",
|
||||
accessibilityNoData: "Context window: No context data",
|
||||
@@ -2978,10 +2966,6 @@ export const en = {
|
||||
requestFailedTitle: "Error",
|
||||
requestFailedMessage:
|
||||
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
|
||||
closeTerminals: "Restart and close terminals",
|
||||
closeTerminalsHint: "Restart and also close every terminal running on this host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"This will restart the daemon and close every terminal on it, ending the programs running in them. The app will reconnect automatically.",
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2106,19 +2106,6 @@ export const es: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Despedir",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Daemon desactualizado",
|
||||
versions: "{{host}} ejecuta el daemon {{daemonVersion}}. Esta app es {{appVersion}}.",
|
||||
terminalsStayOpen: "Actualizar reinicia el daemon. Las terminales siguen abiertas.",
|
||||
terminalsClose: "Actualizar reinicia el daemon y cierra las terminales abiertas.",
|
||||
desktopUpdateTerminals:
|
||||
"Actualizar Paseo Desktop mantiene las terminales abiertas en macOS y Linux, y las cierra en Windows.",
|
||||
desktopManaged: "Paseo Desktop administra este daemon. Actualiza Paseo en esa computadora.",
|
||||
selfUpdateUnsupported:
|
||||
"Este daemon no puede actualizarse solo. Actualiza Paseo en esa computadora.",
|
||||
update: "Actualizar",
|
||||
updateFailedTitle: "No se pudo actualizar el daemon",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "No hay datos de contexto",
|
||||
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
|
||||
@@ -2910,11 +2897,6 @@ export const es: TranslationResources = {
|
||||
requestFailedTitle: "Error",
|
||||
requestFailedMessage:
|
||||
"No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.",
|
||||
closeTerminals: "Reiniciar y cerrar terminales",
|
||||
closeTerminalsHint:
|
||||
"Reinicia y además cierra todas las terminales abiertas en este host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Esto reiniciará el daemon y cerrará todas sus terminales, terminando los programas que se ejecutan en ellas. La app se reconectará automáticamente.",
|
||||
dialogFailedMessage:
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
|
||||
@@ -2105,19 +2105,6 @@ export const fr: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Ignorer",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Daemon obsolète",
|
||||
versions: "{{host}} exécute le daemon {{daemonVersion}}. Cette app est en {{appVersion}}.",
|
||||
terminalsStayOpen: "La mise à jour redémarre le daemon. Les terminaux restent ouverts.",
|
||||
terminalsClose: "La mise à jour redémarre le daemon et ferme les terminaux ouverts.",
|
||||
desktopUpdateTerminals:
|
||||
"La mise à jour de Paseo Desktop garde les terminaux ouverts sur macOS et Linux, et les ferme sur Windows.",
|
||||
desktopManaged: "Paseo Desktop gère ce daemon. Mettez à jour Paseo sur cet ordinateur.",
|
||||
selfUpdateUnsupported:
|
||||
"Ce daemon ne peut pas se mettre à jour lui-même. Mettez à jour Paseo sur cet ordinateur.",
|
||||
update: "Mettre à jour",
|
||||
updateFailedTitle: "Échec de la mise à jour du daemon",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Aucune donnée de contexte",
|
||||
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
|
||||
@@ -2915,10 +2902,6 @@ export const fr: TranslationResources = {
|
||||
requestFailedTitle: "Erreur",
|
||||
requestFailedMessage:
|
||||
"Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.",
|
||||
closeTerminals: "Redémarrer et fermer les terminaux",
|
||||
closeTerminalsHint: "Redémarre et ferme aussi tous les terminaux ouverts sur cet hôte.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Le daemon va redémarrer et fermer tous ses terminaux, ce qui arrêtera les programmes qui y tournent. L'app se reconnectera automatiquement.",
|
||||
dialogFailedMessage:
|
||||
"Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.",
|
||||
},
|
||||
|
||||
@@ -2078,20 +2078,6 @@ export const ja: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "閉じる",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "デーモンが古くなっています",
|
||||
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
|
||||
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
|
||||
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
|
||||
desktopUpdateTerminals:
|
||||
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
|
||||
desktopManaged:
|
||||
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
|
||||
selfUpdateUnsupported:
|
||||
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
|
||||
update: "更新",
|
||||
updateFailedTitle: "デーモンを更新できませんでした",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "コンテキストデータがありません",
|
||||
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
|
||||
@@ -2879,10 +2865,6 @@ export const ja: TranslationResources = {
|
||||
requestFailedTitle: "エラー",
|
||||
requestFailedMessage:
|
||||
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
|
||||
closeTerminals: "再起動してターミナルを閉じる",
|
||||
closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。",
|
||||
closeTerminalsConfirmMessage:
|
||||
"デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。",
|
||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2068,20 +2068,6 @@ export const ko: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "닫기",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "데몬이 오래되었습니다",
|
||||
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
|
||||
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
|
||||
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
|
||||
desktopUpdateTerminals:
|
||||
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
|
||||
desktopManaged:
|
||||
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
||||
selfUpdateUnsupported:
|
||||
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
||||
update: "업데이트",
|
||||
updateFailedTitle: "데몬 업데이트 실패",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "컨텍스트 데이터 없음",
|
||||
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
|
||||
@@ -2866,10 +2852,6 @@ export const ko: TranslationResources = {
|
||||
requestFailedTitle: "오류",
|
||||
requestFailedMessage:
|
||||
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
|
||||
closeTerminals: "재시작하고 터미널 닫기",
|
||||
closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.",
|
||||
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2091,19 +2091,6 @@ export const ptBR: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Dispensar",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Daemon desatualizado",
|
||||
versions: "{{host}} executa o daemon {{daemonVersion}}. Este app é {{appVersion}}.",
|
||||
terminalsStayOpen: "Atualizar reinicia o daemon. Os terminais continuam abertos.",
|
||||
terminalsClose: "Atualizar reinicia o daemon e fecha os terminais abertos.",
|
||||
desktopUpdateTerminals:
|
||||
"Atualizar o Paseo Desktop mantém os terminais abertos no macOS e no Linux, e os fecha no Windows.",
|
||||
desktopManaged: "O Paseo Desktop gerencia este daemon. Atualize o Paseo nesse computador.",
|
||||
selfUpdateUnsupported:
|
||||
"Este daemon não consegue se atualizar sozinho. Atualize o Paseo nesse computador.",
|
||||
update: "Atualizar",
|
||||
updateFailedTitle: "Falha ao atualizar o daemon",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Sem dados de contexto",
|
||||
accessibilityNoData: "Janela de contexto: sem dados de contexto",
|
||||
@@ -2893,10 +2880,6 @@ export const ptBR: TranslationResources = {
|
||||
requestFailedTitle: "Erro",
|
||||
requestFailedMessage:
|
||||
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
|
||||
closeTerminals: "Reiniciar e fechar terminais",
|
||||
closeTerminalsHint: "Reinicia e também fecha todos os terminais abertos neste host.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Isso reiniciará o daemon e fechará todos os terminais dele, encerrando os programas que rodam neles. O app vai se reconectar automaticamente.",
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2091,18 +2091,6 @@ export const ru: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Закрыть",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Демон устарел",
|
||||
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
|
||||
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
|
||||
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
|
||||
desktopUpdateTerminals:
|
||||
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
|
||||
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
|
||||
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
|
||||
update: "Обновить",
|
||||
updateFailedTitle: "Не удалось обновить демон",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Нет данных о контексте",
|
||||
accessibilityNoData: "Контекстное окно: нет данных о контексте",
|
||||
@@ -2902,10 +2890,6 @@ export const ru: TranslationResources = {
|
||||
requestFailedTitle: "Ошибка",
|
||||
requestFailedMessage:
|
||||
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
|
||||
closeTerminals: "Перезапустить и закрыть терминалы",
|
||||
closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.",
|
||||
closeTerminalsConfirmMessage:
|
||||
"Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.",
|
||||
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -2036,18 +2036,6 @@ export const zhCN: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "关闭",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "守护进程版本过旧",
|
||||
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
|
||||
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
|
||||
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
|
||||
desktopUpdateTerminals:
|
||||
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
|
||||
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
|
||||
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
|
||||
update: "更新",
|
||||
updateFailedTitle: "守护进程更新失败",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "暂无上下文数据",
|
||||
accessibilityNoData: "上下文窗口:暂无上下文数据",
|
||||
@@ -2816,10 +2804,6 @@ export const zhCN: TranslationResources = {
|
||||
offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。",
|
||||
requestFailedTitle: "错误",
|
||||
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
|
||||
closeTerminals: "重启并关闭终端",
|
||||
closeTerminalsHint: "重启的同时关闭此主机上的所有终端。",
|
||||
closeTerminalsConfirmMessage:
|
||||
"这将重启守护进程并关闭其所有终端,终端中运行的程序会被结束。应用会自动重新连接。",
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey } from "./settings/query";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
const rpc = settingsRpc("preferences");
|
||||
type Catalog = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>;
|
||||
type Observation = ReturnType<DaemonClient["observeEvents"]>;
|
||||
type Observer = Parameters<Observation["subscribe"]>[0];
|
||||
|
||||
class SettingsStore {
|
||||
private revision = 1;
|
||||
private values = { label: "original" };
|
||||
reads = 0;
|
||||
|
||||
read() {
|
||||
this.reads++;
|
||||
return { status: "ready", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
|
||||
write(input: unknown) {
|
||||
const write = rpc.write.input.parse(input);
|
||||
if (write.revision !== String(this.revision)) {
|
||||
return { status: "conflict", error: "Settings changed" } as const;
|
||||
}
|
||||
const values = write.values;
|
||||
if (typeof values !== "object" || values === null || !("label" in values)) {
|
||||
throw new Error("Expected settings values");
|
||||
}
|
||||
if (typeof values.label !== "string") throw new Error("Expected label");
|
||||
this.values = { label: values.label };
|
||||
this.revision++;
|
||||
return { status: "saved", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */
|
||||
class CatalogClient extends DaemonClient {
|
||||
catalog = catalog("one");
|
||||
fetches = 0;
|
||||
releases = 0;
|
||||
events: Parameters<DaemonClient["observeEvents"]>[0] = [];
|
||||
private readonly observers = new Set<Observer>();
|
||||
|
||||
constructor(readonly settings = new SettingsStore()) {
|
||||
super({ url: "ws://unused.test", clientId: "catalog-sync-test" });
|
||||
}
|
||||
|
||||
override async getPluginCatalog(): Promise<Catalog> {
|
||||
this.fetches++;
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
override observeEvents(events: Parameters<DaemonClient["observeEvents"]>[0]): Observation {
|
||||
this.events = events;
|
||||
const snapshot = { requestId: "events", subscriptionId: "catalog" };
|
||||
const subscribers = new Set<Observer>();
|
||||
return {
|
||||
subscriptionId: snapshot.subscriptionId,
|
||||
ready: Promise.resolve(snapshot),
|
||||
subscribe: (observer) => {
|
||||
subscribers.add(observer);
|
||||
this.observers.add(observer);
|
||||
observer.snapshot(snapshot);
|
||||
return () => {
|
||||
subscribers.delete(observer);
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
},
|
||||
release: async () => {
|
||||
this.releases++;
|
||||
for (const observer of subscribers) this.observers.delete(observer);
|
||||
subscribers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async invokePluginRpc(_pluginId: string, method: string, input: unknown) {
|
||||
if (method === rpc.read.name) return this.settings.read();
|
||||
if (method === rpc.write.name) return this.settings.write(input);
|
||||
throw new Error(`Unexpected RPC: ${method}`);
|
||||
}
|
||||
|
||||
update(message: SessionOutboundMessage) {
|
||||
for (const observer of this.observers) observer.update(message);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
for (const observer of this.observers) {
|
||||
observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function catalog(marker: string): Catalog {
|
||||
return [
|
||||
{
|
||||
id: "reports",
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
clientBundle: `(function() { return { default: function(plugin) {
|
||||
plugin.addTimelineTransformer({
|
||||
id: "report", query: { itemType: "tool_call" },
|
||||
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; }
|
||||
});
|
||||
return function() { plugin.openSettings("cleanup"); };
|
||||
} }; })`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) cleanup();
|
||||
});
|
||||
|
||||
function harness() {
|
||||
let evaluations = 0;
|
||||
let disposals = 0;
|
||||
const registry = new PluginRegistry({
|
||||
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
|
||||
version: appPackage.version,
|
||||
createRuntime(installation) {
|
||||
evaluations++;
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected runtime RPC");
|
||||
},
|
||||
openSettings: () => {
|
||||
disposals++;
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const client = new CatalogClient();
|
||||
cleanups.push(() => {
|
||||
registry.removeHost("host");
|
||||
registry.removeHost("other");
|
||||
});
|
||||
function sync(options: Partial<Parameters<typeof syncPluginCatalog>[0]> = {}) {
|
||||
const stop = syncPluginCatalog({
|
||||
serverId: "host",
|
||||
client,
|
||||
audio,
|
||||
connected: true,
|
||||
supported: true,
|
||||
registry,
|
||||
...options,
|
||||
});
|
||||
cleanups.push(stop);
|
||||
return stop;
|
||||
}
|
||||
function installed(serverId = "host") {
|
||||
const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId);
|
||||
if (!plugin) throw new Error(`Missing installation: ${serverId}`);
|
||||
return plugin;
|
||||
}
|
||||
return {
|
||||
registry,
|
||||
client,
|
||||
sync,
|
||||
installed,
|
||||
evaluations: () => evaluations,
|
||||
disposals: () => disposals,
|
||||
};
|
||||
}
|
||||
|
||||
async function connected() {
|
||||
const h = harness();
|
||||
const stop = h.sync();
|
||||
await expect.poll(() => h.registry.getSnapshot().length).toBe(1);
|
||||
return { ...h, stop };
|
||||
}
|
||||
|
||||
function settingsQuery(plugin: InstalledPlugin) {
|
||||
return {
|
||||
queryKey: pluginSettingsKey("preferences"),
|
||||
queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})),
|
||||
// The replica policy used by useSettings deliberately disables automatic refetches.
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
it("retains installations and timeline transforms while disconnected", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const transformers = plugin.timelineTransformers;
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.installed().timelineTransformers).toBe(transformers);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(false);
|
||||
expect(h.disposals()).toBe(0);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
expect(h.client.events).toEqual([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["mounted", "unmounted"])(
|
||||
"refreshes %s settings and revisions after another client saves offline",
|
||||
async (surface) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const options = settingsQuery(plugin);
|
||||
const original = await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
cleanups.push(unsubscribe);
|
||||
if (surface === "unmounted") unsubscribe();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "retained");
|
||||
const otherClient = new CatalogClient();
|
||||
h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio });
|
||||
const otherPlugin = h.installed("other");
|
||||
await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin));
|
||||
// Another host publishes new presentation snapshots without replacing the owning lifetime.
|
||||
const beforeReconnect = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
const clientB = new CatalogClient(h.client.settings);
|
||||
expect(
|
||||
await clientB.invokePluginRpc("reports", rpc.write.name, {
|
||||
revision: original.revision,
|
||||
values: { label: "client B" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "2", values: { label: "client B" } });
|
||||
expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
h.sync();
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "client B" },
|
||||
});
|
||||
const refreshed = await plugin.queryClient.fetchQuery(options);
|
||||
expect(
|
||||
await plugin.invoke(rpc.write.name, {
|
||||
revision: refreshed.revision,
|
||||
values: { label: "client A" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "3", values: { label: "client A" } });
|
||||
expect(h.registry.getSnapshot()).toBe(beforeReconnect);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers);
|
||||
expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained");
|
||||
expect(otherClient.settings.reads).toBe(1);
|
||||
expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false);
|
||||
expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces changed bundles on reconnect and disposes the old installation", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "old");
|
||||
h.stop();
|
||||
h.client.catalog = catalog("two");
|
||||
h.sync();
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(h.disposals()).toBe(1);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.stop();
|
||||
h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio });
|
||||
expect(h.registry.getSnapshot()).toEqual([]);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
h.client.settings.write({ revision: "1", values: { label: "replayed" } });
|
||||
h.client.snapshot();
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "replayed" },
|
||||
});
|
||||
expect(h.installed()).toBe(plugin);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
|
||||
it("invalidates only the named settings on its host for a live notification", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
cleanups.push(observer.subscribe(() => {}));
|
||||
plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged");
|
||||
const other = new CatalogClient();
|
||||
h.registry.installCatalog("other", other.catalog, { client: other, audio });
|
||||
await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other")));
|
||||
|
||||
const beforeNotification = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
h.client.settings.write({ revision: "1", values: { label: "live" } });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "plugin_settings_changed",
|
||||
pluginId: "reports",
|
||||
settingsId: "preferences",
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "live" },
|
||||
});
|
||||
expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(other.settings.reads).toBe(1);
|
||||
expect(h.registry.getSnapshot()).toBe(beforeNotification);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores an in-flight catalog snapshot after disconnect", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
await plugin.queryClient.fetchQuery(settingsQuery(plugin));
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
h.stop();
|
||||
let resolveCatalog: (value: Catalog) => void = () => {};
|
||||
const pending = new Promise<Catalog>((resolve) => {
|
||||
resolveCatalog = resolve;
|
||||
});
|
||||
let fetching: () => void = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
fetching = resolve;
|
||||
});
|
||||
class PendingClient extends CatalogClient {
|
||||
override getPluginCatalog() {
|
||||
fetching();
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
const stop = h.sync({ client: new PendingClient(h.client.settings) });
|
||||
await started;
|
||||
stop();
|
||||
resolveCatalog(catalog("late"));
|
||||
await pending;
|
||||
// Drain the catalog continuation through a subsequently queued task.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.client.settings.reads).toBe(1);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query";
|
||||
|
||||
interface CatalogSyncInput {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
audio: Pick<AudioEngine, "play"> | null;
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
|
||||
}
|
||||
|
||||
export function syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry,
|
||||
}: CatalogSyncInput): () => void {
|
||||
if (!supported || !audio) {
|
||||
registry.removeHost(serverId);
|
||||
return () => {};
|
||||
}
|
||||
// A dropped socket reconnects on the same client, so installations keep rendering offline.
|
||||
if (!connected) return () => {};
|
||||
const installationOptions = { client, audio };
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
function refresh(replacePluginId?: string) {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
}
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh().then(() => {
|
||||
if (cancelled) return undefined;
|
||||
// Retained replicas miss offline writes and do not refetch when a screen mounts.
|
||||
for (const plugin of registry.getSnapshot()) {
|
||||
if (plugin.serverId !== serverId) continue;
|
||||
void plugin.queryClient.invalidateQueries({
|
||||
queryKey: pluginSettingsPrefix,
|
||||
refetchType: "all",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = registry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { pluginSettingsKey } from "./settings/use-settings";
|
||||
import { useEffect } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
@@ -17,18 +17,72 @@ export function PluginCatalogSync({
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry: pluginRegistry,
|
||||
}),
|
||||
[audio, client, connected, serverId, supported],
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
if (!supported || !audio) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
const refresh = (replacePluginId?: string) => {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
audio,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
};
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = pluginRegistry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}, [audio, client, connected, serverId, supported]);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||
const ensureDirectory = async () => {
|
||||
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
};
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
await ensureDirectory();
|
||||
return FileSystem.readDirectoryAsync(directory);
|
||||
},
|
||||
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||
async writeAtomic(name, contents) {
|
||||
await ensureDirectory();
|
||||
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||
try {
|
||||
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||
} finally {
|
||||
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||
}
|
||||
},
|
||||
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||
},
|
||||
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||
const STORE_NAME = "bundles";
|
||||
let opening: Promise<IDBDatabase> | undefined;
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
}).catch((error) => {
|
||||
opening = undefined;
|
||||
throw error;
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||
async function run<T>(
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(STORE_NAME));
|
||||
transaction.addEventListener("complete", () => resolve(request.result));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
}
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||
.filter((key): key is string => typeof key === "string")
|
||||
.map((key) => key.slice(prefix.length));
|
||||
},
|
||||
async read(name) {
|
||||
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||
return result;
|
||||
},
|
||||
async writeAtomic(name, contents) {
|
||||
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||
},
|
||||
async remove(name) {
|
||||
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||
},
|
||||
},
|
||||
async (contents) => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createHostPluginClientBundleStore } from "./index.web";
|
||||
|
||||
async function sha256(contents: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||
const clientBundle = "bundle".repeat(250_000);
|
||||
const entry = {
|
||||
id: "same-plugin",
|
||||
clientBundle,
|
||||
clientBundleHash: await sha256(clientBundle),
|
||||
};
|
||||
const otherCode = "other host code";
|
||||
const other = {
|
||||
id: entry.id,
|
||||
clientBundle: otherCode,
|
||||
clientBundleHash: await sha256(otherCode),
|
||||
};
|
||||
try {
|
||||
await first.replace([entry]);
|
||||
await second.replace([other]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||
await first.replace([]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||
expect(await second.load()).toEqual([other]);
|
||||
} finally {
|
||||
await first.replace([]);
|
||||
await second.replace([]);
|
||||
}
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test } from "vitest";
|
||||
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||
|
||||
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
class BundleFiles implements PluginBundleFiles {
|
||||
readonly contents = new Map<string, string>();
|
||||
readonly writes: string[] = [];
|
||||
async list() {
|
||||
return [...this.contents.keys()];
|
||||
}
|
||||
async read(name: string) {
|
||||
const contents = this.contents.get(name);
|
||||
if (contents === undefined) throw new Error("File disappeared");
|
||||
return contents;
|
||||
}
|
||||
async writeAtomic(name: string, contents: string) {
|
||||
this.writes.push(name);
|
||||
this.contents.set(name, contents);
|
||||
}
|
||||
async remove(name: string) {
|
||||
this.contents.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundle(id: string, clientBundle: string) {
|
||||
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||
}
|
||||
|
||||
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||
const files = new BundleFiles();
|
||||
const old = await bundle("changed", "old");
|
||||
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||
const cold = createPluginClientBundleStore(files, sha256);
|
||||
expect(await cold.load()).toEqual([old, kept]);
|
||||
const changed = await bundle("changed", "new");
|
||||
await cold.replace([kept, changed]);
|
||||
expect(files.contents.size).toBe(2);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||
expect(files.writes).toHaveLength(3);
|
||||
await cold.replace([]);
|
||||
expect(files.contents.size).toBe(0);
|
||||
});
|
||||
|
||||
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||
const files = new BundleFiles();
|
||||
const entries = await Promise.all([
|
||||
bundle("same-length", "good"),
|
||||
bundle("truncated", "long"),
|
||||
bundle("missing", "lost"),
|
||||
]);
|
||||
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||
const names = [...files.contents.keys()];
|
||||
files.contents.set(names[0]!, "evil");
|
||||
files.contents.set(names[1]!, "lo");
|
||||
// The directory entry still exists while reading the file fails.
|
||||
const unreadable: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) =>
|
||||
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||
remove: (name) => files.remove(name),
|
||||
};
|
||||
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||
expect(await cold.load()).toEqual([]);
|
||||
await cold.replace(entries);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||
});
|
||||
|
||||
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||
const files = new BundleFiles();
|
||||
const previous = await bundle("plugin", "previous");
|
||||
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||
const failing: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) => files.read(name),
|
||||
remove: (name) => files.remove(name),
|
||||
writeAtomic: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||
).rejects.toThrow("Disk full");
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||
});
|
||||
|
||||
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||
test.each(["valid", "corrupt", "missing"])(
|
||||
"cold catalog uses a %s persisted bundle correctly",
|
||||
async (state) => {
|
||||
const files = new BundleFiles();
|
||||
const entry = await bundle("plugin", "good code");
|
||||
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||
if (state === "missing") files.contents.clear();
|
||||
let open = () => {};
|
||||
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "plugin-cache-test",
|
||||
reconnect: { enabled: false },
|
||||
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||
transportFactory: () => ({
|
||||
send(data) {
|
||||
if (typeof data !== "string") return;
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type !== "session") return;
|
||||
const request = frame.message;
|
||||
if (request.type !== "plugin.catalog.get.request") return;
|
||||
requests.push(request);
|
||||
const { clientBundle: _code, ...reference } = entry;
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugins: [request.cachedClientBundles ? reference : entry],
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
close() {},
|
||||
onOpen(handler) {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onMessage(handler) {
|
||||
receive = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose() {
|
||||
return () => {};
|
||||
},
|
||||
onError() {
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const connecting = client.connect();
|
||||
open();
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "cache-host",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { plugins: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await connecting;
|
||||
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||
if (state === "valid")
|
||||
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||
]);
|
||||
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
import type {
|
||||
PluginClientBundle,
|
||||
PluginClientBundleStore,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||
export interface PluginBundleFiles {
|
||||
list(): Promise<string[]>;
|
||||
read(name: string): Promise<string>;
|
||||
writeAtomic(name: string, contents: string): Promise<void>;
|
||||
remove(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPluginClientBundleStore(
|
||||
files: PluginBundleFiles,
|
||||
sha256: (contents: string) => Promise<string>,
|
||||
): PluginClientBundleStore {
|
||||
const verified = new Set<string>();
|
||||
const filename = (bundle: PluginClientBundle) =>
|
||||
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||
return {
|
||||
async load() {
|
||||
const bundles: PluginClientBundle[] = [];
|
||||
for (const name of await files.list()) {
|
||||
try {
|
||||
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||
if (!match) continue;
|
||||
const id = decodeURIComponent(match[1]!);
|
||||
const clientBundleHash = match[2]!;
|
||||
const clientBundle = await files.read(name);
|
||||
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||
verified.add(name);
|
||||
bundles.push({ id, clientBundleHash, clientBundle });
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
},
|
||||
async replace(bundles) {
|
||||
const retained = new Set<string>();
|
||||
for (const bundle of bundles) {
|
||||
const name = filename(bundle);
|
||||
retained.add(name);
|
||||
if (verified.has(name)) continue;
|
||||
await files.writeAtomic(name, bundle.clientBundle);
|
||||
verified.add(name);
|
||||
}
|
||||
for (const name of await files.list()) {
|
||||
if (retained.has(name)) continue;
|
||||
await files.remove(name);
|
||||
verified.delete(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -219,10 +219,7 @@ test("locale snapshots preserve installation state and observations, then releas
|
||||
expect(after.paseo).toBe(before.paseo);
|
||||
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
||||
expect(h.open).toEqual(new Set(["notes"]));
|
||||
const translatedSnapshot = h.plugins.getSnapshot();
|
||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
|
||||
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
|
||||
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
||||
h.plugins.removeHost("host");
|
||||
expect(listeners.size).toBe(0);
|
||||
|
||||
@@ -27,12 +27,12 @@ const pluginRegistry = {
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
||||
options: { replacePluginId?: string; client?: DaemonClient } = {},
|
||||
options: { replacePluginId?: string } = {},
|
||||
) {
|
||||
return registry.installCatalog(
|
||||
serverId,
|
||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||
{ client: daemonClient, ...options, audio },
|
||||
{ ...options, client: daemonClient, audio },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -118,37 +118,6 @@ describe("PluginRegistry", () => {
|
||||
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the snapshot when the same catalog is installed again", () => {
|
||||
const clientBundle = timelineBundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
const snapshot = pluginRegistry.getSnapshot();
|
||||
const transformers = snapshot[0]?.timelineTransformers;
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = pluginRegistry.subscribe(listener);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
unsubscribe();
|
||||
|
||||
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
|
||||
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls an unchanged bundle for a new host client", () => {
|
||||
const clientBundle = bundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
||||
client: {} as DaemonClient,
|
||||
});
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second).not.toBe(first);
|
||||
expect(first?.lifetime.signal.aborted).toBe(true);
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces the query cache when the plugin bundle changes", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
@@ -19,8 +19,6 @@ export class PluginRegistry {
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
|
||||
private readonly clientByHost = new Map<string, DaemonClient>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
@@ -51,15 +49,10 @@ export class PluginRegistry {
|
||||
},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const previousErrors = this.hostEvaluationErrors(serverId);
|
||||
// Installations call the client they were created with, so a new client replaces them all.
|
||||
const sameClient = this.clientByHost.get(serverId) === options.client;
|
||||
this.clientByHost.set(serverId, options.client);
|
||||
const previousTimelineBundles = previous
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
const preserved = catalog.flatMap((entry) => {
|
||||
if (!sameClient) return [];
|
||||
const existing = previous.find(
|
||||
(plugin) =>
|
||||
plugin.id !== options.replacePluginId &&
|
||||
@@ -153,13 +146,13 @@ export class PluginRegistry {
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
// An unchanged catalog keeps the snapshot, so timeline projections cached against the
|
||||
// installed transformers stay valid across a reconnect.
|
||||
const unchanged =
|
||||
installed.length === previous.length &&
|
||||
installed.every((plugin, index) => plugin === previous[index]) &&
|
||||
this.hostEvaluationErrors(serverId) === previousErrors;
|
||||
if (!unchanged) this.publish();
|
||||
if (
|
||||
installed.length !== catalog.length ||
|
||||
installed.length !== previous.length ||
|
||||
installed.some((plugin, index) => plugin !== previous[index])
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
const installedTimelineBundles = installed
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
@@ -177,17 +170,9 @@ export class PluginRegistry {
|
||||
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
||||
}
|
||||
this.byHost.delete(serverId);
|
||||
this.clientByHost.delete(serverId);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private hostEvaluationErrors(serverId: string): string {
|
||||
const prefix = `${serverId}/`;
|
||||
return JSON.stringify(
|
||||
[...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(),
|
||||
);
|
||||
}
|
||||
|
||||
private dispose(plugin: InstalledPlugin): void {
|
||||
if (this.disposed.has(plugin)) return;
|
||||
this.disposed.add(plugin);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const pluginSettingsPrefix = ["plugin-settings"] as const;
|
||||
export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const;
|
||||
@@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { z, type ZodType } from "zod";
|
||||
import { pluginSettingsKey } from "./query";
|
||||
|
||||
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
|
||||
function message(error: unknown): string {
|
||||
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
@@ -590,7 +589,6 @@ function createDefaultDeps(
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
providerSnapshots: "wire",
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
@@ -670,7 +668,6 @@ function createDefaultDeps(
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -687,7 +684,6 @@ function createDefaultDeps(
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
},
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
|
||||
@@ -12,69 +12,6 @@ test("settings restart completes when a replacement worker is observed without a
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("settings restart keeps terminals by default and closes them only when asked", async () => {
|
||||
let pid = 10;
|
||||
const requests: Array<{ killTerminals: boolean }> = [];
|
||||
const deps = {
|
||||
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
|
||||
restartServer: async (_reason: string, options: { killTerminals: boolean }) => {
|
||||
requests.push(options);
|
||||
pid += 1;
|
||||
},
|
||||
};
|
||||
await restartDaemonFromSettings("daemon", "settings", deps);
|
||||
await restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true });
|
||||
expect(requests).toEqual([{ killTerminals: false }, { killTerminals: true }]);
|
||||
});
|
||||
|
||||
test("a close-terminals restart completes when the replacement reports every terminal closed", async () => {
|
||||
let pid = 10;
|
||||
await restartDaemonFromSettings(
|
||||
"daemon",
|
||||
"settings",
|
||||
{
|
||||
getStatus: async () => ({
|
||||
pid,
|
||||
version: "1.0.0",
|
||||
serverId: "daemon",
|
||||
terminalCleanupUnconfirmed: [],
|
||||
}),
|
||||
restartServer: async () => {
|
||||
pid = 11;
|
||||
},
|
||||
},
|
||||
{ killTerminals: true },
|
||||
);
|
||||
expect(pid).toBe(11);
|
||||
});
|
||||
|
||||
test("a close-terminals restart fails with the processes the replacement could not confirm closed", async () => {
|
||||
let pid = 10;
|
||||
const leftover = {
|
||||
pid: 4242,
|
||||
role: "terminal program",
|
||||
reason: "still alive",
|
||||
detail: "survived SIGKILL",
|
||||
};
|
||||
const deps = {
|
||||
getStatus: async () => ({
|
||||
pid,
|
||||
version: "1.0.0",
|
||||
serverId: "daemon",
|
||||
terminalCleanupUnconfirmed: pid === 10 ? [] : [leftover],
|
||||
}),
|
||||
restartServer: async () => {
|
||||
pid += 1;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }),
|
||||
).rejects.toThrow("pid 4242 (terminal program): still alive (survived SIGKILL)");
|
||||
// Keeping terminals asks nothing of the cleanup, so a leftover does not fail it.
|
||||
await restartDaemonFromSettings("daemon", "settings", deps);
|
||||
});
|
||||
|
||||
test("a status permission failure prevents the restart request", async () => {
|
||||
let restarted = false;
|
||||
await expect(
|
||||
|
||||
@@ -1,61 +1,35 @@
|
||||
interface UnconfirmedTerminalProcess {
|
||||
pid: number;
|
||||
role: string;
|
||||
reason: string;
|
||||
detail: string;
|
||||
}
|
||||
interface WorkerStatus {
|
||||
pid: number;
|
||||
serverId: string;
|
||||
version: string | null;
|
||||
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup to report.
|
||||
terminalCleanupUnconfirmed?: UnconfirmedTerminalProcess[];
|
||||
}
|
||||
interface StatusReader {
|
||||
getStatus: () => Promise<WorkerStatus>;
|
||||
}
|
||||
|
||||
export interface SettingsDaemonRestartDeps extends StatusReader {
|
||||
restartServer: (reason: string, options: { killTerminals: boolean }) => Promise<unknown>;
|
||||
restartServer: (reason: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledgment starts the wait; only a different ready worker completes it. Terminals survive
|
||||
* the restart unless `killTerminals` is set (daemons advertising terminalsSurviveRestart); then
|
||||
* the replacement must also report every terminal process gone.
|
||||
*/
|
||||
/** Acknowledgment starts the wait; only a different ready worker completes it. */
|
||||
export async function restartDaemonFromSettings(
|
||||
hostServerId: string,
|
||||
reason: string,
|
||||
deps: SettingsDaemonRestartDeps,
|
||||
options: { killTerminals: boolean } = { killTerminals: false },
|
||||
): Promise<void> {
|
||||
const previous = await readSelectedWorker(hostServerId, deps);
|
||||
let acknowledged = false;
|
||||
try {
|
||||
await deps.restartServer(reason, options);
|
||||
await deps.restartServer(reason);
|
||||
acknowledged = true;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
}
|
||||
let replacement: WorkerStatus;
|
||||
try {
|
||||
replacement = await observeReplacement(previous, deps);
|
||||
await observeReplacement(previous, deps);
|
||||
} catch (error) {
|
||||
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
|
||||
}
|
||||
const unconfirmed = replacement.terminalCleanupUnconfirmed ?? [];
|
||||
if (options.killTerminals && unconfirmed.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"The daemon restarted, but closing its terminals was not confirmed; these processes may still be running:",
|
||||
...unconfirmed.map(
|
||||
(entry) => `pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`,
|
||||
),
|
||||
"Stopping the daemon retries the cleanup.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDaemonFromSettings(
|
||||
|
||||
@@ -565,15 +565,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const runtime = getHostRuntimeStore();
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
// Rendered in the card, not an Alert.alert: react-native-web's Alert is a no-op, and a failed
|
||||
// close-terminals restart must name the processes left running until the user retries.
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
|
||||
const terminalsSurviveRestart = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[host.serverId]?.serverInfo?.features?.terminalsSurviveRestart === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -586,80 +578,71 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(async (request: Promise<void>) => {
|
||||
try {
|
||||
await request;
|
||||
} catch (error) {
|
||||
console.error("[HostPage] Daemon restart failed", error);
|
||||
if (isMountedRef.current) {
|
||||
setRestartError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRestarting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startRestart = useCallback(
|
||||
(killTerminals: boolean) => {
|
||||
if (!daemonClient) {
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (request: Promise<void>) => {
|
||||
try {
|
||||
await request;
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsRestarting(false);
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: killTerminals
|
||||
? t("settings.host.daemon.restart.closeTerminalsConfirmMessage")
|
||||
: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: killTerminals
|
||||
? t("settings.host.daemon.restart.closeTerminals")
|
||||
: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
setRestartError(null);
|
||||
setIsRestarting(true);
|
||||
const restartRequest = restartDaemonFromSettings(
|
||||
host.serverId,
|
||||
`settings_daemon_restart_${host.serverId}`,
|
||||
{
|
||||
restartServer: (reason, options) =>
|
||||
daemonClient.restartServer(reason, undefined, options),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
},
|
||||
{ killTerminals },
|
||||
);
|
||||
void waitForDaemonRestart(restartRequest);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
if (isMountedRef.current) {
|
||||
setRestartError(t("settings.host.daemon.restart.dialogFailedMessage"));
|
||||
}
|
||||
});
|
||||
},
|
||||
[daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRestart = useCallback(() => startRestart(false), [startRestart]);
|
||||
const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]);
|
||||
const handleRestart = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.unavailableTitle"),
|
||||
t("settings.host.daemon.restart.unavailableMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.offlineTitle"),
|
||||
t("settings.host.daemon.restart.offlineMessage"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
|
||||
message: t("settings.host.daemon.restart.confirmMessage"),
|
||||
confirmLabel: t("settings.host.daemon.restart.confirm"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
setIsRestarting(true);
|
||||
const restartRequest = restartDaemonFromSettings(
|
||||
host.serverId,
|
||||
`settings_daemon_restart_${host.serverId}`,
|
||||
{
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
void waitForDaemonRestart(restartRequest);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
||||
t("settings.host.daemon.restart.dialogFailedMessage"),
|
||||
);
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-restart-card">
|
||||
@@ -681,35 +664,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
: t("settings.host.daemon.restart.confirm")}
|
||||
</Button>
|
||||
</View>
|
||||
{terminalsSurviveRestart ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.host.daemon.restart.closeTerminalsHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleRestartAndCloseTerminals}
|
||||
disabled={isRestarting || !daemonClient || !isConnected}
|
||||
testID="host-page-restart-close-terminals-button"
|
||||
>
|
||||
{t("settings.host.daemon.restart.closeTerminals")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{restartError ? (
|
||||
<View style={styles.cardAlert}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.host.daemon.restart.requestFailedTitle")}
|
||||
description={restartError}
|
||||
testID="host-page-restart-error"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -894,7 +848,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
/>
|
||||
) : null}
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.cardAlert}>
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
@@ -1748,7 +1702,7 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
cardAlert: {
|
||||
updateFailure: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
|
||||
@@ -371,22 +371,6 @@ describe("viewed timeline persistence", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preparing an already painted cache again keeps history out of the live head", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
await replica.prepare(AGENT_ID);
|
||||
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
|
||||
expect(painted).toEqual(cachedTimeline().items);
|
||||
await replica.prepare(AGENT_ID);
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID]!;
|
||||
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
|
||||
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
|
||||
});
|
||||
|
||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
@@ -109,15 +109,6 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
const existing = this.preparations.get(agentId);
|
||||
if (existing) return existing;
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[this.serverId],
|
||||
agentId,
|
||||
);
|
||||
// A route and the visible-chat owner can both prepare before catch-up finishes.
|
||||
// Repainting that same cache would treat its history as a live head.
|
||||
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
|
||||
@@ -129,23 +129,6 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const createPluginClientBundleStore = () => ({
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
});
|
||||
const { client } = await connectToDaemon(
|
||||
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
|
||||
{ createPluginClientBundleStore },
|
||||
probe.deps,
|
||||
);
|
||||
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
|
||||
@@ -132,13 +132,12 @@ function resolveConnectionCredentials(
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
options: {
|
||||
options?: {
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
password?: string;
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
} = {},
|
||||
},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
| "getClientId"
|
||||
@@ -158,9 +157,8 @@ export async function buildClientConfig(
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...resolveConnectionCredentials(connection, options),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options.trace ? { trace: options.trace } : {}),
|
||||
createPluginClientBundleStore: options.createPluginClientBundleStore,
|
||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options?.trace ? { trace: options.trace } : {}),
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
desktopTransportFactory
|
||||
? { transportFactory: desktopTransportFactory }
|
||||
@@ -290,7 +288,6 @@ interface ProbeOptions {
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
}
|
||||
|
||||
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/server": "0.14.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -3,11 +3,8 @@ import {
|
||||
readDaemonInstance,
|
||||
isSameDaemonInstance,
|
||||
DaemonInstanceError,
|
||||
retryUnconfirmedTerminalCleanup,
|
||||
TerminalCleanupNotConfirmedError,
|
||||
} from "@getpaseo/server/daemon-control";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { DaemonGetStatusResponse } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
@@ -23,30 +20,13 @@ export function daemonRestartCommand(): Command {
|
||||
),
|
||||
)
|
||||
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
|
||||
.option("--kill-terminals", "Close every terminal instead of keeping them across the restart")
|
||||
.action(withOutput(runRestartCommand));
|
||||
}
|
||||
|
||||
type DaemonStatus = DaemonGetStatusResponse["payload"];
|
||||
|
||||
export async function runRestartCommand(options: CommandOptions, _command: Command) {
|
||||
const target = options.daemonTarget;
|
||||
const deadline = Date.now() + parseTimeoutMs(options.timeout);
|
||||
const remaining = () => Math.max(1, deadline - Date.now());
|
||||
const killTerminals = options.killTerminals === true;
|
||||
// The supervisor reaps before the respawn and the replacement retries what that left;
|
||||
// anything still named by a closing record survived both. A local caller retries once more
|
||||
// itself; a remote one has only what the replacement reports.
|
||||
const confirmTerminalsClosed = async (status: DaemonStatus) => {
|
||||
if (!killTerminals) return;
|
||||
if (target.kind === "instance") {
|
||||
await retryUnconfirmedTerminalCleanup(target.home);
|
||||
return;
|
||||
}
|
||||
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup.
|
||||
const unconfirmed = status.terminalCleanupUnconfirmed ?? [];
|
||||
if (unconfirmed.length > 0) throw new TerminalCleanupNotConfirmedError(unconfirmed);
|
||||
};
|
||||
const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
|
||||
async function checkSupervisor() {
|
||||
if (target.kind !== "instance") return;
|
||||
@@ -74,10 +54,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
|
||||
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
|
||||
await checkSupervisor();
|
||||
try {
|
||||
await client.restartServer("cli_restart", undefined, {
|
||||
timeout: remaining(),
|
||||
killTerminals,
|
||||
});
|
||||
await client.restartServer("cli_restart", undefined, { timeout: remaining() });
|
||||
acknowledged = true;
|
||||
} catch (error) {
|
||||
if (!isReconnectFailure(error)) throw error;
|
||||
@@ -100,7 +77,6 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
|
||||
const status = await replacement.getDaemonStatus({ timeout: remaining() });
|
||||
if (status.pid !== workerPid) {
|
||||
await checkSupervisor();
|
||||
await confirmTerminalsClosed(status);
|
||||
return {
|
||||
type: "single" as const,
|
||||
data: {
|
||||
|
||||
@@ -103,7 +103,6 @@ async function probeDaemonStatus(
|
||||
daemonNode: status.nodePath,
|
||||
providers: status.providers,
|
||||
relay: status.relay,
|
||||
...(status.terminalsUnavailable ? { terminals: status.terminalsUnavailable } : {}),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { hostname, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { createStopCommandRunner, type StopCommandDeps } from "./stop.js";
|
||||
|
||||
// The daemon the gate is checked against: a connection whose server_info may or may not
|
||||
// advertise terminalsSurviveRestart, and that records any shutdown it is asked for.
|
||||
function createFakeDaemon(features: { terminalsSurviveRestart?: boolean } = {}) {
|
||||
const shutdownRequests: Array<{ keepTerminals?: boolean }> = [];
|
||||
const deps: StopCommandDeps = {
|
||||
connectToDaemon: async () => ({
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "fake-daemon",
|
||||
version: "0.11.0",
|
||||
features,
|
||||
}),
|
||||
shutdownServer: async (options) => {
|
||||
shutdownRequests.push({ keepTerminals: options?.keepTerminals });
|
||||
return { status: "shutdown_requested", clientId: "fake-client", requestId: "fake-request" };
|
||||
},
|
||||
close: async () => undefined,
|
||||
}),
|
||||
};
|
||||
return { deps, shutdownRequests };
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup();
|
||||
});
|
||||
|
||||
function stop(options: Record<string, unknown>, deps: StopCommandDeps) {
|
||||
return createStopCommandRunner(deps)(options as never, new Command("stop"));
|
||||
}
|
||||
|
||||
// A home whose lock names a live stand-in supervisor, so the local stop path runs for real.
|
||||
function homeWithRunningSupervisor(): { home: string; supervisor: ChildProcess } {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-stop-keep-"));
|
||||
const supervisor = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(home, "paseo.pid"),
|
||||
JSON.stringify({
|
||||
pid: supervisor.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
hostname: hostname(),
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen: "127.0.0.1:1",
|
||||
}),
|
||||
);
|
||||
cleanups.push(() => {
|
||||
supervisor.kill("SIGKILL");
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
return { home, supervisor };
|
||||
}
|
||||
|
||||
describe("daemon stop --keep-terminals", () => {
|
||||
test("refuses an endpoint daemon that does not advertise terminalsSurviveRestart", async () => {
|
||||
const daemon = createFakeDaemon();
|
||||
await expect(
|
||||
stop(
|
||||
{ daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true },
|
||||
daemon.deps,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" });
|
||||
expect(daemon.shutdownRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"refuses a local daemon without the capability and never signals its supervisor",
|
||||
async () => {
|
||||
const daemon = createFakeDaemon();
|
||||
const { home, supervisor } = homeWithRunningSupervisor();
|
||||
await expect(
|
||||
stop({ daemonTarget: { kind: "instance", home }, keepTerminals: true }, daemon.deps),
|
||||
).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" });
|
||||
expect(daemon.shutdownRequests).toEqual([]);
|
||||
expect(supervisor.exitCode).toBeNull();
|
||||
expect(supervisor.signalCode).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("asks a capable daemon to keep its terminals", async () => {
|
||||
const daemon = createFakeDaemon({ terminalsSurviveRestart: true });
|
||||
await stop(
|
||||
{ daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true },
|
||||
daemon.deps,
|
||||
);
|
||||
expect(daemon.shutdownRequests).toEqual([{ keepTerminals: true }]);
|
||||
});
|
||||
|
||||
test("rejects --force with --keep-terminals before contacting the daemon", async () => {
|
||||
const daemon = createFakeDaemon({ terminalsSurviveRestart: true });
|
||||
const { home } = homeWithRunningSupervisor();
|
||||
await expect(
|
||||
stop(
|
||||
{ daemonTarget: { kind: "instance", home }, keepTerminals: true, force: true },
|
||||
daemon.deps,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
expect(daemon.shutdownRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
requireTerminalKeepCapability,
|
||||
type DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
import { connectToDaemon, type ConnectOptions } from "../../utils/client.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { withOutput, type CommandOptions } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { describeDaemonTarget } from "../../utils/daemon-target.js";
|
||||
@@ -14,43 +10,19 @@ export function daemonStopCommand(): Command {
|
||||
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
|
||||
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
|
||||
.option("--force", "Permit forced local process cleanup")
|
||||
.option(
|
||||
"--keep-terminals",
|
||||
"Leave terminals running; the next daemon started for this home takes them back",
|
||||
)
|
||||
.option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
|
||||
.action(withOutput(runStopCommand));
|
||||
}
|
||||
|
||||
// The daemon connection stop needs; stop.test.ts passes an in-memory adapter.
|
||||
export type StopDaemonConnection = Pick<
|
||||
DaemonClient,
|
||||
"getLastServerInfoMessage" | "shutdownServer" | "close"
|
||||
>;
|
||||
|
||||
export interface StopCommandDeps {
|
||||
connectToDaemon(options: ConnectOptions): Promise<StopDaemonConnection>;
|
||||
}
|
||||
|
||||
export const runStopCommand = createStopCommandRunner({ connectToDaemon });
|
||||
|
||||
export function createStopCommandRunner(deps: StopCommandDeps) {
|
||||
return (options: CommandOptions, _command: Command) => runStop(options, deps);
|
||||
}
|
||||
|
||||
async function runStop(options: CommandOptions, deps: StopCommandDeps) {
|
||||
export async function runStopCommand(options: CommandOptions, _command: Command) {
|
||||
const target = options.daemonTarget;
|
||||
const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remaining = () => Math.max(1, deadline - Date.now());
|
||||
const keepTerminals = options.keepTerminals === true;
|
||||
const requestShutdown = async (instance?: DaemonInstance) => {
|
||||
const client = await deps.connectToDaemon({ target, timeout: remaining(), instance });
|
||||
const client = await connectToDaemon({ target, timeout: remaining(), instance });
|
||||
try {
|
||||
// Checked on the connection that carries the shutdown: a daemon without the capability
|
||||
// would close the terminals it was asked to keep.
|
||||
if (keepTerminals) requireTerminalKeepCapability(client, describeDaemonTarget(target));
|
||||
await client.shutdownServer({ timeout: remaining(), keepTerminals });
|
||||
await client.shutdownServer({ timeout: remaining() });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
@@ -60,11 +32,6 @@ async function runStop(options: CommandOptions, deps: StopCommandDeps) {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--force requires a local --home; an endpoint gives no remote process authority.",
|
||||
};
|
||||
if (options.force && keepTerminals)
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--force kills terminals; it cannot be combined with --keep-terminals.",
|
||||
};
|
||||
const result: {
|
||||
action: string;
|
||||
home?: string;
|
||||
@@ -77,7 +44,6 @@ async function runStop(options: CommandOptions, deps: StopCommandDeps) {
|
||||
? {
|
||||
...(await stopDaemonInstance(target.home, {
|
||||
force: options.force === true,
|
||||
keepTerminals,
|
||||
timeoutMs,
|
||||
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
|
||||
requestShutdown,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts";
|
||||
|
||||
// Port zero leaves allocation to the listening daemon, with no reserve/release gap.
|
||||
const daemon = await startTestDaemon({ port: 0 });
|
||||
try {
|
||||
assert(daemon.port > 0, "the helper returns the port the daemon actually bound");
|
||||
assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`);
|
||||
const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Terminals survive `paseo daemon restart` and `stop --keep-terminals`; `restart
|
||||
* --kill-terminals`, `stop` and `stop --force` close them. Runs the built CLI and daemon in a
|
||||
* throwaway home; every process it starts is stopped in `finally`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
if (process.platform === "win32") {
|
||||
console.log("Skipping terminal survival CLI test on Windows (POSIX shell terminals).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("=== Daemon terminals survive restart ===\n");
|
||||
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-terminals-survive-"));
|
||||
const workDir = join(paseoHome, "work");
|
||||
await mkdir(workDir, { recursive: true });
|
||||
const port = await getAvailablePort();
|
||||
const env = {
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
// The daemon inherits this; terminals then run a plain POSIX shell with no startup files.
|
||||
SHELL: "/bin/sh",
|
||||
};
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor<T>(
|
||||
probe: () => Promise<T | null | false> | T | null | false,
|
||||
message: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function paseo(args: string[], expectSuccess = true) {
|
||||
const result = await runLocalPaseo(args, env);
|
||||
if (expectSuccess) {
|
||||
assert.equal(
|
||||
result.exitCode,
|
||||
0,
|
||||
`paseo ${args.join(" ")} failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function json<T>(args: string[]): Promise<T> {
|
||||
return JSON.parse((await paseo([...args, "--json"])).stdout) as T;
|
||||
}
|
||||
|
||||
async function capture(terminalId: string): Promise<string> {
|
||||
const { lines } = await json<{ lines: string[] }>(["terminal", "capture", terminalId]);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function waitForScreen(terminalId: string, text: string): Promise<void> {
|
||||
await waitFor(
|
||||
async () => (await capture(terminalId)).includes(text),
|
||||
`terminal ${terminalId} never showed ${text}:\n${await capture(terminalId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function terminalIds(): Promise<string[]> {
|
||||
return (await json<Array<{ id: string }>>(["terminal", "ls", "--all"])).map((t) => t.id);
|
||||
}
|
||||
|
||||
// Worker records and the cleanup records a kill that could not finish leaves beside them.
|
||||
function workerRecordCount(): number {
|
||||
const dir = join(paseoHome, "terminal-worker");
|
||||
return existsSync(dir)
|
||||
? readdirSync(dir).filter((name) => /^(\d+|closing-.+)\.json$/.test(name)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
// A shell terminal that reports its pid and prints a marker.
|
||||
async function openTerminal(name: string): Promise<{ id: string; pid: number }> {
|
||||
const terminal = await json<{ id: string }>([
|
||||
"terminal",
|
||||
"create",
|
||||
"--cwd",
|
||||
workDir,
|
||||
"--name",
|
||||
name,
|
||||
]);
|
||||
const pidFile = join(workDir, `${name}.pid`);
|
||||
await paseo([
|
||||
"terminal",
|
||||
"send-keys",
|
||||
terminal.id,
|
||||
"-l",
|
||||
`echo $$ > '${pidFile}'; echo MARKER-${name}`,
|
||||
]);
|
||||
await paseo(["terminal", "send-keys", terminal.id, "Enter"]);
|
||||
const pid = await waitFor(
|
||||
() =>
|
||||
existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()
|
||||
? Number(readFileSync(pidFile, "utf8"))
|
||||
: null,
|
||||
`terminal ${name} never wrote its pid`,
|
||||
);
|
||||
await waitForScreen(terminal.id, `MARKER-${name}`);
|
||||
return { id: terminal.id, pid };
|
||||
}
|
||||
|
||||
async function expectKept(terminal: { id: string; pid: number }, name: string, trigger: string) {
|
||||
assert((await terminalIds()).includes(terminal.id), `${trigger}: terminal is still listed`);
|
||||
assert(isAlive(terminal.pid), `${trigger}: terminal program is still alive`);
|
||||
assert((await capture(terminal.id)).includes(`MARKER-${name}`), `${trigger}: marker is kept`);
|
||||
await paseo(["terminal", "send-keys", terminal.id, "-l", `echo after-${trigger}`]);
|
||||
await paseo(["terminal", "send-keys", terminal.id, "Enter"]);
|
||||
await waitForScreen(terminal.id, `\nafter-${trigger}`);
|
||||
}
|
||||
|
||||
async function expectClosed(pid: number, trigger: string) {
|
||||
await waitFor(() => !isAlive(pid), `${trigger}: terminal program ${pid} is still alive`, 15_000);
|
||||
}
|
||||
|
||||
// A zombie: a real process with a birth time that SIGKILL cannot remove while its parent (an
|
||||
// exec'd `sleep` that never waits) lives. Cleanup can only report it as still alive.
|
||||
async function startZombie(): Promise<{ zombiePid: number; parentPid: number }> {
|
||||
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const zombiePid = await new Promise<number>((resolve) => {
|
||||
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
|
||||
});
|
||||
parent.unref();
|
||||
await waitFor(
|
||||
async () => (await psField(zombiePid, "stat")).startsWith("Z"),
|
||||
`process ${zombiePid} never became a zombie`,
|
||||
);
|
||||
return { zombiePid, parentPid: parent.pid! };
|
||||
}
|
||||
|
||||
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
|
||||
const child = spawn("ps", ["-o", `${field}=`, "-p", String(pid)], {
|
||||
env: { ...process.env, LC_ALL: "C" },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
let out = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString()));
|
||||
await new Promise((resolve) => child.once("close", resolve));
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
async function goneProcessPid(): Promise<number> {
|
||||
const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
return child.pid!;
|
||||
}
|
||||
|
||||
// A record as a dead terminal worker leaves it, naming one terminal program that is a zombie.
|
||||
async function plantRecordNaming(programPid: number): Promise<void> {
|
||||
const workerPid = await goneProcessPid();
|
||||
const record = {
|
||||
recordId: "planted-record",
|
||||
pid: workerPid,
|
||||
bootId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
|
||||
protocolVersion: 1,
|
||||
paseoVersion: "0.0.0-test",
|
||||
socketPath: join(paseoHome, "planted-worker.sock"),
|
||||
secret: "planted-secret",
|
||||
terminals: [
|
||||
{ id: "planted-terminal", pid: programPid, startedAt: await psField(programPid, "lstart") },
|
||||
],
|
||||
};
|
||||
writeFileSync(
|
||||
join(paseoHome, "terminal-worker", `${workerPid}.json`),
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
const trackedPids: number[] = [];
|
||||
|
||||
try {
|
||||
for (const [field, value] of [
|
||||
["daemon.listen", `127.0.0.1:${port}`],
|
||||
["daemon.relay.enabled", "false"],
|
||||
["features.dictation.enabled", "false"],
|
||||
["features.voiceMode.enabled", "false"],
|
||||
] as const) {
|
||||
await paseo(["daemon", "config", "set", field, value]);
|
||||
}
|
||||
await paseo(["daemon", "start"]);
|
||||
|
||||
console.log("Test 1: daemon restart keeps the terminal");
|
||||
const kept = await openTerminal("kept");
|
||||
trackedPids.push(kept.pid);
|
||||
await paseo(["daemon", "restart", "--home", paseoHome]);
|
||||
await expectKept(kept, "kept", "restart");
|
||||
console.log("✓ same id, same program, marker and input after restart\n");
|
||||
|
||||
console.log("Test 2: stop --keep-terminals, then start, gives the terminal back");
|
||||
const forceAndKeep = await paseo(
|
||||
["daemon", "stop", "--home", paseoHome, "--force", "--keep-terminals"],
|
||||
false,
|
||||
);
|
||||
assert.notEqual(forceAndKeep.exitCode, 0, "--force with --keep-terminals is refused");
|
||||
await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]);
|
||||
assert.equal(await readDaemonInstance(paseoHome), null, "the supervisor stopped");
|
||||
assert(isAlive(kept.pid), "the program outlives the stopped daemon");
|
||||
await paseo(["daemon", "start"]);
|
||||
await expectKept(kept, "kept", "keep-stop");
|
||||
console.log("✓ adopted with the same id and still usable\n");
|
||||
|
||||
console.log("Test 3: a plain stop with no daemon running closes kept terminals");
|
||||
await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]);
|
||||
assert(isAlive(kept.pid), "kept again");
|
||||
await paseo(["daemon", "stop", "--home", paseoHome]);
|
||||
await expectClosed(kept.pid, "stop without a supervisor");
|
||||
assert.equal(workerRecordCount(), 0, "no worker record is left");
|
||||
console.log("✓ closed\n");
|
||||
|
||||
console.log("Test 4: restart --kill-terminals closes the terminal");
|
||||
await paseo(["daemon", "start"]);
|
||||
const killedByRestart = await openTerminal("restart-kill");
|
||||
trackedPids.push(killedByRestart.pid);
|
||||
await paseo(["daemon", "restart", "--home", paseoHome, "--kill-terminals"]);
|
||||
await expectClosed(killedByRestart.pid, "restart --kill-terminals");
|
||||
assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart");
|
||||
console.log("✓ closed\n");
|
||||
|
||||
console.log("Test 5: daemon stop closes the terminal and its worker");
|
||||
const killedByStop = await openTerminal("stop-kill");
|
||||
trackedPids.push(killedByStop.pid);
|
||||
await paseo(["daemon", "stop", "--home", paseoHome]);
|
||||
await expectClosed(killedByStop.pid, "stop");
|
||||
assert.equal(workerRecordCount(), 0, "no worker record is left");
|
||||
console.log("✓ closed\n");
|
||||
|
||||
console.log("Test 6: daemon stop --force closes the terminal");
|
||||
await paseo(["daemon", "start"]);
|
||||
const killedByForce = await openTerminal("force-kill");
|
||||
trackedPids.push(killedByForce.pid);
|
||||
await paseo(["daemon", "stop", "--home", paseoHome, "--force"]);
|
||||
await expectClosed(killedByForce.pid, "stop --force");
|
||||
assert.equal(workerRecordCount(), 0, "no worker record is left");
|
||||
console.log("✓ closed\n");
|
||||
|
||||
const endpoint = `127.0.0.1:${port}`;
|
||||
console.log("Test 7: restart --host --kill-terminals closes the terminal");
|
||||
await paseo(["daemon", "start"]);
|
||||
const killedByEndpointRestart = await openTerminal("endpoint-restart-kill");
|
||||
trackedPids.push(killedByEndpointRestart.pid);
|
||||
await paseo(["daemon", "restart", "--host", endpoint, "--kill-terminals"]);
|
||||
await expectClosed(killedByEndpointRestart.pid, "restart --host --kill-terminals");
|
||||
assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart");
|
||||
console.log("✓ closed and confirmed by the replacement\n");
|
||||
|
||||
console.log("Test 8: restart --host --kill-terminals reports a process cleanup could not end");
|
||||
const zombie = await startZombie();
|
||||
trackedPids.push(zombie.parentPid);
|
||||
await plantRecordNaming(zombie.zombiePid);
|
||||
const unconfirmed = await paseo(
|
||||
["daemon", "restart", "--host", endpoint, "--kill-terminals"],
|
||||
false,
|
||||
);
|
||||
assert.notEqual(unconfirmed.exitCode, 0, "the kill restart fails");
|
||||
const output = unconfirmed.stdout + unconfirmed.stderr;
|
||||
assert(output.includes("Terminal cleanup was not confirmed"), `names the failure:\n${output}`);
|
||||
assert(output.includes(`pid ${zombie.zombiePid}`), `names the surviving pid:\n${output}`);
|
||||
process.kill(zombie.parentPid, "SIGKILL");
|
||||
await expectClosed(zombie.zombiePid, "zombie reaped once its parent exits");
|
||||
await paseo(["daemon", "stop", "--home", paseoHome]);
|
||||
assert.equal(workerRecordCount(), 0, "the next stop retries the cleanup and retires the record");
|
||||
console.log("✓ reported, then retried by stop\n");
|
||||
|
||||
console.log("Test 9: restart --host --kill-terminals reports a worker record it cannot read");
|
||||
await paseo(["daemon", "start"]);
|
||||
// A kill cleanup cannot check what an unreadable record names, and leaves nothing else
|
||||
// behind, so the replacement daemon must still report it.
|
||||
const unreadablePath = join(paseoHome, "terminal-worker", `${await goneProcessPid()}.json`);
|
||||
writeFileSync(unreadablePath, "{ not json", { mode: 0o600 });
|
||||
const unreadable = await paseo(
|
||||
["daemon", "restart", "--host", endpoint, "--kill-terminals"],
|
||||
false,
|
||||
);
|
||||
assert.notEqual(unreadable.exitCode, 0, "the kill restart fails");
|
||||
const unreadableOutput = unreadable.stdout + unreadable.stderr;
|
||||
assert(unreadableOutput.includes(unreadablePath), `names the record:\n${unreadableOutput}`);
|
||||
assert(existsSync(unreadablePath), "the unreadable record is kept");
|
||||
rmSync(unreadablePath);
|
||||
await paseo(["daemon", "stop", "--home", paseoHome]);
|
||||
assert.equal(workerRecordCount(), 0, "no worker record is left");
|
||||
console.log("✓ reported with the record's path\n");
|
||||
} finally {
|
||||
await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"], env);
|
||||
for (const pid of trackedPids) {
|
||||
if (isAlive(pid)) process.kill(pid, "SIGKILL");
|
||||
}
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Daemon terminals survive restart passed ===");
|
||||
@@ -1,527 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* A terminal worker survives an upgrade of the installation that started it. Two disposable
|
||||
* installations A and B (built dist, real copies of @getpaseo/server, @getpaseo/cli and
|
||||
* node-pty, every other dependency a symlink into this repo) each get their own CLI shim, so
|
||||
* each daemon's PASEO_CLI is a distinct path:
|
||||
*
|
||||
* 1. A daemon from A runs its worker from a copy in $PASEO_HOME/terminal-worker/. After
|
||||
* `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away,
|
||||
* a daemon from B adopts the same worker and opens a new, working terminal in it with B's
|
||||
* environment and CLI, while the first terminal keeps A's.
|
||||
* 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version
|
||||
* old drains (keeps its terminal, new ones open in a second worker, it exits after its last
|
||||
* terminal and its copy is deleted); one two versions old is shut down; one from a newer
|
||||
* Paseo is left running, reported as terminal_worker_incompatible, and closed by
|
||||
* `daemon stop`.
|
||||
*
|
||||
* Every process it starts is stopped in `finally`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
if (process.platform === "win32") {
|
||||
console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("=== Terminal worker survives an upgrade ===\n");
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "..", "..", "..");
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-worker-upgrade-"));
|
||||
const paseoHome = join(root, "home");
|
||||
const cliHome = join(root, "os-home");
|
||||
const workDir = join(root, "work");
|
||||
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
|
||||
const port = await getAvailablePort();
|
||||
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
|
||||
|
||||
interface Installation {
|
||||
name: string;
|
||||
dir: string;
|
||||
version: string;
|
||||
shim: string;
|
||||
serverDir: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Installation fixtures
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function linkEntries(source: string, target: string, taken: Set<string>): void {
|
||||
if (!existsSync(source)) return;
|
||||
for (const name of readdirSync(source)) {
|
||||
if (name.startsWith(".")) continue;
|
||||
if (name.startsWith("@")) {
|
||||
mkdirSync(join(target, name), { recursive: true });
|
||||
for (const scoped of readdirSync(join(source, name))) {
|
||||
const key = `${name}/${scoped}`;
|
||||
if (taken.has(key)) continue;
|
||||
taken.add(key);
|
||||
symlinkSync(join(source, name, scoped), join(target, name, scoped));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (taken.has(name)) continue;
|
||||
taken.add(name);
|
||||
symlinkSync(join(source, name), join(target, name));
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallation(name: string, version: string): Installation {
|
||||
const dir = join(root, `install-${name}`);
|
||||
const modules = join(dir, "node_modules");
|
||||
mkdirSync(join(modules, "@getpaseo"), { recursive: true });
|
||||
|
||||
// Real copies: the server (its version marks the installation), the CLI (so it resolves this
|
||||
// installation's server), and node-pty (so moving it away breaks only this installation).
|
||||
const serverDir = join(modules, "@getpaseo", "server");
|
||||
const serverPackage = JSON.parse(
|
||||
readFileSync(join(repoRoot, "packages/server/package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
mkdirSync(join(serverDir, "dist"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(serverDir, "package.json"),
|
||||
`${JSON.stringify({ ...serverPackage, version }, null, 2)}\n`,
|
||||
);
|
||||
for (const part of ["server", "src", "scripts"]) {
|
||||
cpSync(join(repoRoot, "packages/server/dist", part), join(serverDir, "dist", part), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
const cliDir = join(modules, "@getpaseo", "cli");
|
||||
mkdirSync(cliDir, { recursive: true });
|
||||
cpSync(join(repoRoot, "packages/cli/package.json"), join(cliDir, "package.json"));
|
||||
cpSync(join(repoRoot, "packages/cli/dist"), join(cliDir, "dist"), { recursive: true });
|
||||
cpSync(join(repoRoot, "packages/server/node_modules/node-pty"), join(modules, "node-pty"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const taken = new Set(["@getpaseo/server", "@getpaseo/cli", "node-pty"]);
|
||||
for (const source of [
|
||||
"packages/server/node_modules",
|
||||
"packages/cli/node_modules",
|
||||
"node_modules",
|
||||
]) {
|
||||
linkEntries(join(repoRoot, source), modules, taken);
|
||||
}
|
||||
|
||||
const shim = join(dir, "bin", "paseo");
|
||||
mkdirSync(join(dir, "bin"));
|
||||
writeFileSync(
|
||||
shim,
|
||||
`#!/bin/sh\nexec "${process.execPath}" "${join(cliDir, "dist", "index.js")}" "$@"\n`,
|
||||
);
|
||||
chmodSync(shim, 0o755);
|
||||
return { name, dir, version, shim, serverDir };
|
||||
}
|
||||
|
||||
// The runtime files an upgrade replaces: node-pty, the worker bundle, the shell integration.
|
||||
function moveRuntimeAway(installation: Installation): () => void {
|
||||
const away = join(installation.dir, "moved-away");
|
||||
const moves = [
|
||||
join(installation.dir, "node_modules", "node-pty"),
|
||||
join(installation.serverDir, "dist", "server", "terminal-worker"),
|
||||
join(installation.serverDir, "dist", "server", "terminal", "shell-integration"),
|
||||
].map((from, index) => ({ from, to: join(away, String(index)) }));
|
||||
mkdirSync(away, { recursive: true });
|
||||
for (const move of moves) renameSync(move.from, move.to);
|
||||
return () => {
|
||||
for (const move of moves) renameSync(move.to, move.from);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor<T>(
|
||||
probe: () => Promise<T | null | false> | T | null | false,
|
||||
message: () => string | Promise<string>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(await message());
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function run(
|
||||
installation: Installation,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
): Promise<RunResult> {
|
||||
const env = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
|
||||
HOME: cliHome,
|
||||
USERPROFILE: cliHome,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
// Each daemon hands its terminals its own installation's CLI.
|
||||
PASEO_CLI: installation.shim,
|
||||
PASEO_TEST_INSTALL: installation.name,
|
||||
SHELL: "/bin/sh",
|
||||
...extraEnv,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
execFile(installation.shim, args, { env, timeout: 120_000 }, (error, stdout, stderr) => {
|
||||
let exitCode = 0;
|
||||
if (error) exitCode = typeof error.code === "number" ? error.code : 1;
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function paseo(
|
||||
installation: Installation,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
): Promise<RunResult> {
|
||||
const result = await run(installation, args, extraEnv);
|
||||
assert.equal(
|
||||
result.exitCode,
|
||||
0,
|
||||
`paseo ${args.join(" ")} (${installation.name}) failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function json<T>(installation: Installation, args: string[]): Promise<T> {
|
||||
return JSON.parse((await paseo(installation, [...args, "--json"])).stdout) as T;
|
||||
}
|
||||
|
||||
async function capture(installation: Installation, terminalId: string): Promise<string> {
|
||||
const { lines } = await json<{ lines: string[] }>(installation, [
|
||||
"terminal",
|
||||
"capture",
|
||||
terminalId,
|
||||
]);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function type(installation: Installation, terminalId: string, line: string) {
|
||||
await paseo(installation, ["terminal", "send-keys", terminalId, "-l", line]);
|
||||
await paseo(installation, ["terminal", "send-keys", terminalId, "Enter"]);
|
||||
}
|
||||
|
||||
async function waitForScreen(installation: Installation, terminalId: string, text: string) {
|
||||
await waitFor(
|
||||
async () => (await capture(installation, terminalId)).includes(text),
|
||||
async () =>
|
||||
`terminal ${terminalId} never showed ${text}:\n${await capture(installation, terminalId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Opens a terminal, records its shell's pid, and prints a marker.
|
||||
async function openTerminal(
|
||||
installation: Installation,
|
||||
name: string,
|
||||
): Promise<{ id: string; pid: number }> {
|
||||
const terminal = await json<{ id: string }>(installation, [
|
||||
"terminal",
|
||||
"create",
|
||||
"--cwd",
|
||||
workDir,
|
||||
"--name",
|
||||
name,
|
||||
]);
|
||||
const pidFile = join(workDir, `${name}.pid`);
|
||||
await type(installation, terminal.id, `echo $$ > '${pidFile}'; echo MARKER-${name}`);
|
||||
const pid = await waitFor(
|
||||
() =>
|
||||
existsSync(pidFile) && readFileSync(pidFile, "utf8").trim()
|
||||
? Number(readFileSync(pidFile, "utf8"))
|
||||
: null,
|
||||
() => `terminal ${name} never wrote its pid`,
|
||||
);
|
||||
await waitForScreen(installation, terminal.id, `MARKER-${name}`);
|
||||
trackedPids.push(pid);
|
||||
return { id: terminal.id, pid };
|
||||
}
|
||||
|
||||
// The value of `$variable` in the terminal's shell, written to a file (a long path would wrap
|
||||
// on screen).
|
||||
async function echoVariable(
|
||||
installation: Installation,
|
||||
terminalId: string,
|
||||
label: string,
|
||||
variable: string,
|
||||
): Promise<string> {
|
||||
const valueFile = join(workDir, `${label}.value`);
|
||||
await type(
|
||||
installation,
|
||||
terminalId,
|
||||
`printf '%s' "$${variable}" > '${valueFile}.tmp' && mv '${valueFile}.tmp' '${valueFile}'`,
|
||||
);
|
||||
await waitFor(
|
||||
() => existsSync(valueFile),
|
||||
async () =>
|
||||
`terminal ${terminalId} never wrote ${label}:\n${await capture(installation, terminalId)}`,
|
||||
);
|
||||
return readFileSync(valueFile, "utf8");
|
||||
}
|
||||
|
||||
interface WorkerRecordFile {
|
||||
pid: number;
|
||||
protocolVersion: unknown;
|
||||
runtime?: string | null;
|
||||
terminals: Array<{ id: string; pid: number }>;
|
||||
}
|
||||
|
||||
function workerRecords(): WorkerRecordFile[] {
|
||||
const dir = join(paseoHome, "terminal-worker");
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((name) => /^\d+\.json$/.test(name))
|
||||
.map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")) as WorkerRecordFile);
|
||||
}
|
||||
|
||||
function runtimeCopies(): string[] {
|
||||
const dir = join(paseoHome, "terminal-worker");
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter((name) =>
|
||||
existsSync(join(dir, name, "paseo-terminal-worker-runtime.json")),
|
||||
);
|
||||
}
|
||||
|
||||
function commandLineOf(pid: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], (_error, stdout) =>
|
||||
resolve(stdout.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function terminalIds(installation: Installation): Promise<string[]> {
|
||||
return (await json<Array<{ id: string }>>(installation, ["terminal", "ls", "--all"])).map(
|
||||
(terminal) => terminal.id,
|
||||
);
|
||||
}
|
||||
|
||||
async function startDaemon(installation: Installation, extraEnv: Record<string, string> = {}) {
|
||||
await paseo(installation, ["daemon", "start"], extraEnv);
|
||||
}
|
||||
|
||||
async function stopDaemon(installation: Installation, keepTerminals = false) {
|
||||
await paseo(installation, [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--home",
|
||||
paseoHome,
|
||||
...(keepTerminals ? ["--keep-terminals"] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
// A terminal in a worker reporting `protocolVersion`, left running by `stop --keep-terminals`.
|
||||
async function leaveWorkerWithProtocol(
|
||||
installation: Installation,
|
||||
protocolVersion: number,
|
||||
name: string,
|
||||
): Promise<{ terminal: { id: string; pid: number }; workerPid: number; runtime: string }> {
|
||||
await startDaemon(installation, {
|
||||
PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE: String(protocolVersion),
|
||||
});
|
||||
const terminal = await openTerminal(installation, name);
|
||||
const [worker] = workerRecords();
|
||||
assert(worker, "the daemon started a worker");
|
||||
assert.equal(worker.protocolVersion, protocolVersion, "the worker reports the override");
|
||||
await stopDaemon(installation, true);
|
||||
return { terminal, workerPid: worker.pid, runtime: worker.runtime! };
|
||||
}
|
||||
|
||||
const trackedPids: number[] = [];
|
||||
const a = createInstallation("A", "0.98.0");
|
||||
const b = createInstallation("B", "0.99.0");
|
||||
let restoreA: (() => void) | null = null;
|
||||
let passed = false;
|
||||
|
||||
try {
|
||||
for (const [field, value] of [
|
||||
["daemon.listen", `127.0.0.1:${port}`],
|
||||
["daemon.relay.enabled", "false"],
|
||||
["features.dictation.enabled", "false"],
|
||||
["features.voiceMode.enabled", "false"],
|
||||
] as const) {
|
||||
await paseo(a, ["daemon", "config", "set", field, value]);
|
||||
}
|
||||
|
||||
console.log("Test 1: a daemon from A runs its worker from a copy in the home");
|
||||
await startDaemon(a);
|
||||
const t1 = await openTerminal(a, "t1");
|
||||
const [firstWorker] = workerRecords();
|
||||
assert(firstWorker, "one worker runs");
|
||||
assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/);
|
||||
const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!);
|
||||
assert(
|
||||
(await commandLineOf(firstWorker.pid)).includes(join(copyA, "terminal-worker.mjs")),
|
||||
"the worker runs the copy's bundle",
|
||||
);
|
||||
assert.equal(await echoVariable(a, t1.id, "CLI1", "PASEO_CLI"), a.shim, "T1 has A's CLI");
|
||||
console.log("✓ worker runs from", copyA, "\n");
|
||||
|
||||
console.log("Test 2: B adopts the worker after A's runtime files are gone");
|
||||
await stopDaemon(a, true);
|
||||
restoreA = moveRuntimeAway(a);
|
||||
await startDaemon(b, zsh ? { SHELL: zsh } : {});
|
||||
assert.deepEqual(
|
||||
workerRecords().map((record) => record.pid),
|
||||
[firstWorker.pid],
|
||||
"B adopted the same worker instead of starting one",
|
||||
);
|
||||
assert((await terminalIds(b)).includes(t1.id), "T1 is listed by B");
|
||||
const t2 = await openTerminal(b, "t2");
|
||||
assert.equal(await echoVariable(b, t2.id, "INSTALL2", "PASEO_TEST_INSTALL"), "B");
|
||||
assert.equal(await echoVariable(b, t2.id, "CLI2", "PASEO_CLI"), b.shim, "T2 has B's CLI");
|
||||
if (zsh) {
|
||||
// Creating a zsh terminal reads Paseo's integration from the worker's copy (A's is gone);
|
||||
// a missing file would have failed the create.
|
||||
const zshVersion = await echoVariable(b, t2.id, "ZSH", "ZSH_VERSION");
|
||||
assert(zshVersion.length > 0, "T2 runs zsh with Paseo's shell integration");
|
||||
} else {
|
||||
console.log(" (zsh is not installed: the shell-integration check is skipped)");
|
||||
}
|
||||
assert.equal(await echoVariable(b, t1.id, "CLI1AGAIN", "PASEO_CLI"), a.shim, "T1 keeps A's CLI");
|
||||
console.log("✓ same worker, T2 uses B's environment, T1 still works\n");
|
||||
|
||||
console.log("Test 3: daemon stop closes both terminals and deletes the copy");
|
||||
await stopDaemon(b);
|
||||
await waitFor(
|
||||
() => !isAlive(t1.pid) && !isAlive(t2.pid) && !isAlive(firstWorker.pid),
|
||||
() => "terminals or worker outlived daemon stop",
|
||||
);
|
||||
assert.deepEqual(workerRecords(), [], "no worker record is left");
|
||||
assert.deepEqual(runtimeCopies(), [], "no copy is left");
|
||||
restoreA();
|
||||
restoreA = null;
|
||||
console.log("✓ closed and cleaned up\n");
|
||||
|
||||
console.log("Test 4: a worker one protocol version old drains");
|
||||
const old = await leaveWorkerWithProtocol(a, 0, "t3");
|
||||
await startDaemon(b);
|
||||
const workers = workerRecords();
|
||||
assert.equal(workers.length, 2, "the old worker drains beside a new one");
|
||||
const current = workers.find((record) => record.pid !== old.workerPid)!;
|
||||
assert.equal(current.protocolVersion, 1);
|
||||
assert.match(current.runtime ?? "", /^0\.99\.0-/);
|
||||
assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed");
|
||||
await type(b, old.terminal.id, "echo still-served");
|
||||
await waitForScreen(b, old.terminal.id, "\nstill-served");
|
||||
const t4 = await openTerminal(b, "t4");
|
||||
assert.deepEqual(
|
||||
workerRecords()
|
||||
.find((record) => record.pid === current.pid)!
|
||||
.terminals.map((terminal) => terminal.id),
|
||||
[t4.id],
|
||||
"new terminals open in the current worker",
|
||||
);
|
||||
await paseo(b, ["terminal", "kill", old.terminal.id]);
|
||||
await waitFor(
|
||||
() => !isAlive(old.workerPid),
|
||||
() => `the drained worker ${old.workerPid} did not exit`,
|
||||
);
|
||||
await waitFor(
|
||||
() => !runtimeCopies().includes(old.runtime),
|
||||
() => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`,
|
||||
);
|
||||
assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays");
|
||||
await type(b, t4.id, "echo after-drain");
|
||||
await waitForScreen(b, t4.id, "\nafter-drain");
|
||||
await stopDaemon(b);
|
||||
console.log("✓ drained, exited, copy deleted\n");
|
||||
|
||||
console.log("Test 5: a worker two protocol versions old is shut down");
|
||||
const obsolete = await leaveWorkerWithProtocol(a, -1, "t5");
|
||||
await startDaemon(b);
|
||||
await waitFor(
|
||||
() => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid),
|
||||
() => "the obsolete worker or its terminal survived",
|
||||
);
|
||||
assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone");
|
||||
assert.equal(workerRecords().length, 1, "one current worker runs");
|
||||
await stopDaemon(b);
|
||||
console.log("✓ shut down\n");
|
||||
|
||||
console.log("Test 6: a worker from a newer Paseo is left running and reported");
|
||||
const newer = await leaveWorkerWithProtocol(a, 2, "t6");
|
||||
await startDaemon(b);
|
||||
const listed = await run(b, ["terminal", "ls", "--all", "--json"]);
|
||||
assert.notEqual(listed.exitCode, 0, "terminal ls fails");
|
||||
assert.match(listed.stdout + listed.stderr, /held by a newer Paseo \(v0\.98\.0/);
|
||||
const created = await run(b, ["terminal", "create", "--cwd", workDir, "--json"]);
|
||||
assert.notEqual(created.exitCode, 0, "terminal create fails");
|
||||
assert.match(created.stdout + created.stderr, /held by a newer Paseo/);
|
||||
const status = await paseo(b, ["daemon", "status", "--home", paseoHome]);
|
||||
assert.match(status.stdout, /terminals: Terminals are held by a newer Paseo/);
|
||||
assert(isAlive(newer.terminal.pid), "its program keeps running");
|
||||
assert.deepEqual(
|
||||
workerRecords().map((record) => record.pid),
|
||||
[newer.workerPid],
|
||||
"no second worker starts",
|
||||
);
|
||||
await stopDaemon(b);
|
||||
await waitFor(
|
||||
() => !isAlive(newer.workerPid) && !isAlive(newer.terminal.pid),
|
||||
() => "daemon stop left the newer worker or its terminal running",
|
||||
);
|
||||
assert.deepEqual(workerRecords(), [], "no worker record is left");
|
||||
assert.deepEqual(runtimeCopies(), [], "no copy is left");
|
||||
console.log("✓ reported, untouched, closed by stop\n");
|
||||
passed = true;
|
||||
} finally {
|
||||
if (!passed) {
|
||||
for (const log of ["daemon.log", "terminal-worker.log"]) {
|
||||
const logPath = join(paseoHome, log);
|
||||
if (existsSync(logPath)) {
|
||||
console.error(`--- ${log} (last 60 lines) ---`);
|
||||
console.error(readFileSync(logPath, "utf8").split("\n").slice(-60).join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
restoreA?.();
|
||||
for (const installation of [b, a]) {
|
||||
await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]);
|
||||
}
|
||||
for (const pid of trackedPids) {
|
||||
if (isAlive(pid)) process.kill(pid, "SIGKILL");
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Terminal worker survives an upgrade passed ===");
|
||||
@@ -18,7 +18,7 @@ import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
import { getAvailablePort } from "./network.ts";
|
||||
|
||||
export interface TestDaemonContext {
|
||||
/** Available local port for test daemon (never 6767) */
|
||||
@@ -195,20 +195,17 @@ async function probeDaemonReady(
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(
|
||||
port: number,
|
||||
paseoHome: string,
|
||||
timeout = 30000,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<number> {
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
async function poll(): Promise<number> {
|
||||
const instance = await readDaemonInstance(paseoHome);
|
||||
if (instance?.listen) {
|
||||
const port = Number(new URL(`http://${instance.listen}`).port);
|
||||
if (await probeDaemonReady(port, paseoHome, env)) return port;
|
||||
}
|
||||
async function poll(): Promise<void> {
|
||||
if (await probeDaemonReady(port, paseoHome, env)) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`);
|
||||
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
await sleep(100);
|
||||
return poll();
|
||||
@@ -234,14 +231,15 @@ export async function startTestDaemon(options?: {
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<TestDaemonContext> {
|
||||
// Let the listening process allocate its port, without a reserve/release race.
|
||||
const requestedPort = options?.port ?? 0;
|
||||
const port = options?.port ?? (await getAvailablePort());
|
||||
const { paseoHome, workDir } =
|
||||
options?.paseoHome && options?.workDir
|
||||
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||
: await createTempDirs();
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
|
||||
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
|
||||
|
||||
// Find the CLI entry point - use the source file directly with tsx
|
||||
const cliDir = join(import.meta.dirname, "..", "..");
|
||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||
@@ -254,7 +252,7 @@ export async function startTestDaemon(options?: {
|
||||
),
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
@@ -314,18 +312,20 @@ export async function startTestDaemon(options?: {
|
||||
}
|
||||
});
|
||||
|
||||
const ctx: TestDaemonContext = {
|
||||
port,
|
||||
wsUrl,
|
||||
paseoHome,
|
||||
workDir,
|
||||
process: daemonProcess,
|
||||
isReady: false,
|
||||
stop: cleanup,
|
||||
};
|
||||
|
||||
// Wait for daemon to be ready
|
||||
try {
|
||||
const port = await waitForDaemonReady(paseoHome, timeout, options?.env);
|
||||
return {
|
||||
port,
|
||||
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
||||
paseoHome,
|
||||
workDir,
|
||||
process: daemonProcess,
|
||||
isReady: true,
|
||||
stop: cleanup,
|
||||
};
|
||||
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
|
||||
ctx.isReady = true;
|
||||
} catch (err) {
|
||||
// Daemon failed to start - clean up and rethrow
|
||||
await cleanup();
|
||||
@@ -335,6 +335,8 @@ export async function startTestDaemon(options?: {
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DaemonClient,
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
type CreateAgentRequestOptions,
|
||||
type DaemonTransport,
|
||||
type Logger,
|
||||
type PluginClientBundle,
|
||||
type PluginClientBundleStore,
|
||||
} from "./daemon-client";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
@@ -2101,188 +2099,6 @@ test("gets a structured plugin log snapshot", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe("plugin catalog", () => {
|
||||
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
createPluginClientBundleStore: createStore,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { plugins: true } });
|
||||
await connectPromise;
|
||||
return { client, mock };
|
||||
}
|
||||
|
||||
async function fetchCatalog(
|
||||
harness: Awaited<ReturnType<typeof connectedClient>>,
|
||||
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
|
||||
) {
|
||||
const before = harness.mock.sent.length;
|
||||
const response = harness.client.getPluginCatalog();
|
||||
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
|
||||
const request = parseSentFrame(harness.mock.sent.at(-1));
|
||||
harness.mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: { requestId: request.requestId, plugins: respond(request) },
|
||||
}),
|
||||
);
|
||||
return { request, plugins: await response };
|
||||
}
|
||||
|
||||
test("reuses retained client bundles the daemon leaves out", async () => {
|
||||
const harness = await connectedClient();
|
||||
const first = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(first.request).not.toHaveProperty("cachedClientBundles");
|
||||
|
||||
const second = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
|
||||
expect(second.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(second.plugins).toEqual([
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const third = await fetchCatalog(harness, () => []);
|
||||
expect(third.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const fourth = await fetchCatalog(harness, () => []);
|
||||
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
|
||||
});
|
||||
|
||||
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
|
||||
const harness = await connectedClient();
|
||||
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
|
||||
await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
const second = await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
expect(second.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
|
||||
});
|
||||
|
||||
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
|
||||
let persisted: PluginClientBundle[] = [];
|
||||
let loads = 0;
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
loads++;
|
||||
return persisted;
|
||||
},
|
||||
async replace(bundles) {
|
||||
persisted = bundles;
|
||||
},
|
||||
};
|
||||
const createdFor: string[] = [];
|
||||
const createStore = (serverId: string) => {
|
||||
createdFor.push(serverId);
|
||||
return store;
|
||||
};
|
||||
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
|
||||
const warm = await connectedClient(createStore);
|
||||
await fetchCatalog(warm, () => [bundle]);
|
||||
const cold = await connectedClient(createStore);
|
||||
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
|
||||
expect(first.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
]);
|
||||
expect(first.plugins).toEqual([bundle]);
|
||||
expect(loads).toBe(2);
|
||||
await fetchCatalog(cold, () => []);
|
||||
expect(loads).toBe(2);
|
||||
expect(createdFor).toEqual([
|
||||
warm.client.getLastServerInfoMessage()!.serverId,
|
||||
cold.client.getLastServerInfoMessage()!.serverId,
|
||||
]);
|
||||
expect(persisted).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["missing", "unreadable"])(
|
||||
"a %s persistent cache requests full bundles",
|
||||
async (state) => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
if (state === "unreadable") throw new Error("Storage unavailable");
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
|
||||
expect(result.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(result.plugins).toEqual([bundle]);
|
||||
},
|
||||
);
|
||||
|
||||
test("failed cache writes never fail plugin loading", async () => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
expect(
|
||||
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
|
||||
).toEqual([bundle]);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
|
||||
async (failFirstWrite) => {
|
||||
const writes: PluginClientBundle[][] = [];
|
||||
let finishWrite = () => {};
|
||||
const blockedWrite = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
});
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace(bundles) {
|
||||
writes.push(bundles);
|
||||
if (writes.length === 1) {
|
||||
await blockedWrite;
|
||||
if (failFirstWrite) throw new Error("Disk full");
|
||||
}
|
||||
},
|
||||
};
|
||||
const harness = await connectedClient(() => store);
|
||||
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
|
||||
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
|
||||
try {
|
||||
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first]]));
|
||||
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
|
||||
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
|
||||
expect(writes).toEqual([[first]]);
|
||||
finishWrite();
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
|
||||
} finally {
|
||||
finishWrite();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a plugin reload pending past the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
|
||||
@@ -396,17 +396,6 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
|
||||
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
|
||||
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
|
||||
|
||||
export interface PluginClientBundle {
|
||||
id: string;
|
||||
clientBundleHash: string;
|
||||
clientBundle: string;
|
||||
}
|
||||
|
||||
/** Host-scoped storage. load must return only bundles whose contents have been verified. */
|
||||
export interface PluginClientBundleStore {
|
||||
load(): Promise<PluginClientBundle[]>;
|
||||
replace(bundles: PluginClientBundle[]): Promise<void>;
|
||||
}
|
||||
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
|
||||
export type RelaySecurityMode = "legacy" | "protected";
|
||||
|
||||
@@ -417,8 +406,6 @@ export interface DaemonClientConfig {
|
||||
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
|
||||
url: string;
|
||||
clientId: string;
|
||||
/** Create one cache for the confirmed daemon identity, retained for this client lifetime. */
|
||||
createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
@@ -781,9 +768,6 @@ type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayl
|
||||
export interface ShutdownServerOptions {
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
// Leave terminals running for the next daemon. Only daemons advertising
|
||||
// `features.terminalsSurviveRestart` honor it; check before sending.
|
||||
keepTerminals?: boolean;
|
||||
}
|
||||
export interface DaemonStatusOptions {
|
||||
requestId?: string;
|
||||
@@ -1032,12 +1016,6 @@ type CorrelatedResponsePayloads = {
|
||||
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
|
||||
CorrelatedResponsePayloads[TType];
|
||||
|
||||
type PluginCatalogResponseEntry =
|
||||
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
|
||||
export type PluginCatalogEntry = Omit<PluginCatalogResponseEntry, "clientBundle"> & {
|
||||
clientBundle: string;
|
||||
};
|
||||
|
||||
export class DaemonConnectionError extends Error {
|
||||
readonly serverId?: string;
|
||||
readonly outcome?: "unknown";
|
||||
@@ -1289,36 +1267,6 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features
|
||||
return features?.usageSources === true || features?.providerUsageList === true;
|
||||
}
|
||||
|
||||
export class TerminalKeepUnsupportedError extends Error {
|
||||
readonly code = "TERMINALS_KEEP_UNSUPPORTED";
|
||||
// Own fields reach the CLI's JSON error output, which desktop reads.
|
||||
readonly version: string | null;
|
||||
|
||||
constructor(target: string, version: string | null) {
|
||||
super(
|
||||
`The daemon at ${target}${version ? ` (v${version})` : ""} closes terminals when it stops. Update it first, or run \`paseo daemon stop\` to stop it and close them.`,
|
||||
);
|
||||
this.name = "TerminalKeepUnsupportedError";
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for an explicit "keep terminals" stop. Older daemons drop `keepTerminals` and close the
|
||||
* terminals anyway, so read the capability on the connection that will carry the shutdown and
|
||||
* refuse before sending it.
|
||||
*/
|
||||
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
|
||||
export function requireTerminalKeepCapability(
|
||||
client: Pick<DaemonClient, "getLastServerInfoMessage">,
|
||||
target: string,
|
||||
): void {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (serverInfo?.features?.terminalsSurviveRestart !== true) {
|
||||
throw new TerminalKeepUnsupportedError(target, serverInfo?.version ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchAgentsRequest(
|
||||
options: FetchAgentsOptions | undefined,
|
||||
requestId: string,
|
||||
@@ -1395,15 +1343,6 @@ export class DaemonClient {
|
||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
private completedBinaryFileReads = new Map<string, FileReadResult>();
|
||||
/** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */
|
||||
private pluginClientBundles = new Map<
|
||||
string,
|
||||
{ clientBundleHash: string; clientBundle: string }
|
||||
>();
|
||||
private pluginClientBundleStore: PluginClientBundleStore | undefined;
|
||||
private pluginClientBundlesLoaded: Promise<void> | undefined;
|
||||
private pluginClientBundlesWrite: PluginClientBundle[] | undefined;
|
||||
private pluginClientBundlesWriting = false;
|
||||
private logger: Logger;
|
||||
private pendingSendQueue: PendingSend[] = [];
|
||||
/**
|
||||
@@ -4104,14 +4043,13 @@ export class DaemonClient {
|
||||
async restartServer(
|
||||
reason?: string,
|
||||
requestId?: string,
|
||||
options?: { timeout?: number; killTerminals?: boolean },
|
||||
options?: { timeout?: number },
|
||||
): Promise<RestartRequestedStatusPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "restart_server_request",
|
||||
...(reason && reason.trim().length > 0 ? { reason } : {}),
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.killTerminals ? { killTerminals: true } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
@@ -4139,7 +4077,6 @@ export class DaemonClient {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "shutdown_server_request",
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.keepTerminals ? { keepTerminals: true } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
@@ -5856,82 +5793,14 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginCatalog(): Promise<PluginCatalogEntry[]> {
|
||||
if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) {
|
||||
const serverInfo = this.getLastServerInfoMessage();
|
||||
if (!serverInfo) throw new Error("Plugin catalog requires a connected host");
|
||||
this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId);
|
||||
}
|
||||
const store = this.pluginClientBundleStore;
|
||||
if (store) {
|
||||
this.pluginClientBundlesLoaded ??= store.load().then(
|
||||
(bundles) => {
|
||||
for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle);
|
||||
return undefined;
|
||||
},
|
||||
(error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"),
|
||||
);
|
||||
await this.pluginClientBundlesLoaded;
|
||||
}
|
||||
async getPluginCatalog() {
|
||||
const requestId = this.createRequestId();
|
||||
const cached = this.pluginClientBundles;
|
||||
const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({
|
||||
id,
|
||||
clientBundleHash,
|
||||
}));
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId,
|
||||
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
|
||||
},
|
||||
message: { type: "plugin.catalog.get.request", requestId },
|
||||
responseType: "plugin.catalog.get.response",
|
||||
});
|
||||
const retained = new Map<string, { clientBundleHash: string; clientBundle: string }>();
|
||||
const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => {
|
||||
const previous = cached.get(plugin.id);
|
||||
const clientBundle =
|
||||
sent ??
|
||||
(previous?.clientBundleHash === plugin.clientBundleHash
|
||||
? previous?.clientBundle
|
||||
: undefined);
|
||||
if (clientBundle === undefined) {
|
||||
throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`);
|
||||
}
|
||||
// COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after
|
||||
// 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained.
|
||||
if (plugin.clientBundleHash !== undefined) {
|
||||
retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle });
|
||||
}
|
||||
return Object.assign(plugin, { clientBundle });
|
||||
});
|
||||
this.pluginClientBundles = retained;
|
||||
if (store) {
|
||||
this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) =>
|
||||
Object.assign({ id }, bundle),
|
||||
);
|
||||
void this.persistPluginClientBundles(store);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private async persistPluginClientBundles(store: PluginClientBundleStore): Promise<void> {
|
||||
if (this.pluginClientBundlesWriting) return;
|
||||
this.pluginClientBundlesWriting = true;
|
||||
try {
|
||||
while (this.pluginClientBundlesWrite) {
|
||||
const bundles = this.pluginClientBundlesWrite;
|
||||
this.pluginClientBundlesWrite = undefined;
|
||||
try {
|
||||
await store.replace(bundles);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist plugin client bundles");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.pluginClientBundlesWriting = false;
|
||||
}
|
||||
return payload.plugins;
|
||||
}
|
||||
|
||||
async listPlugins(): Promise<PluginListItem[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -4,31 +4,6 @@ import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js";
|
||||
import { createNodeEntrypointInvocation } from "../runtime-paths.js";
|
||||
import { resolveExternalCliEntrypoint } from "./entrypoints.js";
|
||||
|
||||
/** A CLI command that exited non-zero, with the `--json` error it printed when there was one. */
|
||||
export class ExternalCliCommandError extends Error {
|
||||
readonly code: string | null;
|
||||
readonly error: Record<string, unknown> | null;
|
||||
|
||||
constructor(message: string, error: Record<string, unknown> | null) {
|
||||
super(message);
|
||||
this.name = "ExternalCliCommandError";
|
||||
this.error = error;
|
||||
this.code = typeof error?.code === "string" ? error.code : null;
|
||||
}
|
||||
}
|
||||
|
||||
// `--json` commands print `{ "error": { code, message, ... } }` on stderr when they fail.
|
||||
function parseJsonCliError(stderr: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(stderr) as { error?: unknown };
|
||||
return typeof parsed.error === "object" && parsed.error !== null
|
||||
? (parsed.error as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation {
|
||||
return createNodeEntrypointInvocation({
|
||||
entrypoint: resolveExternalCliEntrypoint(),
|
||||
@@ -110,10 +85,7 @@ export async function runExternalCliJsonCommand(args: string[]): Promise<unknown
|
||||
stderr: stderr.slice(0, 500),
|
||||
command: invocation.command,
|
||||
});
|
||||
throw new ExternalCliCommandError(
|
||||
externalCliFailureMessage(result.exitCode, stdout, stderr),
|
||||
parseJsonCliError(stderr),
|
||||
);
|
||||
throw new Error(externalCliFailureMessage(result.exitCode, stdout, stderr));
|
||||
}
|
||||
|
||||
const stdout = result.stdout.trim();
|
||||
|
||||
@@ -4,13 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
import { ExternalCliCommandError } from "./cli/external";
|
||||
import {
|
||||
createDaemonCommandHandlers,
|
||||
keepsTerminalsOnStop,
|
||||
stopCapturedDaemon,
|
||||
type DesktopDaemonStopReason,
|
||||
} from "./daemon-manager";
|
||||
import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
paseoHome: "",
|
||||
@@ -79,8 +73,7 @@ vi.mock("./runtime-paths.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./cli/external.js", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
vi.mock("./cli/external.js", () => ({
|
||||
runExternalCliJsonCommand: mocks.runExternalCliJsonCommand,
|
||||
runExternalCliTextCommand: mocks.runExternalCliTextCommand,
|
||||
}));
|
||||
@@ -183,107 +176,3 @@ describe("daemon-manager commands", () => {
|
||||
expect(await handler({ listen: "localhost:6799" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop stops and terminals", () => {
|
||||
const reasons: DesktopDaemonStopReason[] = [
|
||||
"manual_ipc",
|
||||
"settings",
|
||||
"host_remove",
|
||||
"quit",
|
||||
"app_update",
|
||||
"version_mismatch",
|
||||
"restart",
|
||||
];
|
||||
|
||||
it("keeps terminals only for an app update or version-mismatch restart, never on Windows", () => {
|
||||
for (const platform of ["darwin", "linux"] as const) {
|
||||
expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, platform))).toEqual([
|
||||
"app_update",
|
||||
"version_mismatch",
|
||||
]);
|
||||
}
|
||||
expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, "win32"))).toEqual([]);
|
||||
});
|
||||
|
||||
function stopHarness(runCliJson: (args: string[]) => Promise<unknown>) {
|
||||
const stops: Array<{ keepTerminals: boolean | undefined }> = [];
|
||||
const logged: string[] = [];
|
||||
const instance = {
|
||||
pid: 4242,
|
||||
startedAt: "2026-10-21T00:00:00.000Z",
|
||||
hostname: "host",
|
||||
uid: 501,
|
||||
listen: "127.0.0.1:6799",
|
||||
};
|
||||
const stopInstance = (async (
|
||||
_home: string,
|
||||
options: {
|
||||
keepTerminals?: boolean;
|
||||
requestShutdown?: (ready: { listen: string }) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
stops.push({ keepTerminals: options.keepTerminals });
|
||||
await options.requestShutdown?.({ listen: instance.listen });
|
||||
return { action: "stopped", pid: instance.pid, forced: false, usedLifecycleRpc: true };
|
||||
}) as unknown as NonNullable<Parameters<typeof stopCapturedDaemon>[0]["stopInstance"]>;
|
||||
return {
|
||||
stops,
|
||||
logged,
|
||||
input: {
|
||||
home: "/tmp/paseo-home",
|
||||
instance,
|
||||
stopInstance,
|
||||
runCliJson,
|
||||
log: (message: string) => logged.push(message),
|
||||
} as Omit<Parameters<typeof stopCapturedDaemon>[0], "reason" | "keepTerminals">,
|
||||
};
|
||||
}
|
||||
|
||||
it("asks the daemon to keep its terminals on an app update", async () => {
|
||||
const calls: string[][] = [];
|
||||
const harness = stopHarness(async (args) => {
|
||||
calls.push(args);
|
||||
return { action: "shutdown_requested" };
|
||||
});
|
||||
|
||||
await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true });
|
||||
|
||||
expect(harness.stops).toEqual([{ keepTerminals: true }]);
|
||||
expect(calls).toEqual([
|
||||
["daemon", "stop", "--host", "127.0.0.1:6799", "--keep-terminals", "--json"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops a daemon without the capability with its terminals closed, and says so", async () => {
|
||||
const calls: string[][] = [];
|
||||
const harness = stopHarness(async (args) => {
|
||||
calls.push(args);
|
||||
if (args.includes("--keep-terminals")) {
|
||||
throw new ExternalCliCommandError("refused", {
|
||||
code: "TERMINALS_KEEP_UNSUPPORTED",
|
||||
version: "0.11.0",
|
||||
});
|
||||
}
|
||||
return { action: "shutdown_requested" };
|
||||
});
|
||||
|
||||
await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true });
|
||||
|
||||
expect(harness.stops).toEqual([{ keepTerminals: true }, { keepTerminals: false }]);
|
||||
expect(calls[1]).toEqual(["daemon", "stop", "--host", "127.0.0.1:6799", "--json"]);
|
||||
expect(harness.logged).toContain(
|
||||
"daemon v0.11.0 cannot keep terminals; stopping it and closing them",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not retry a keep stop that failed for another reason", async () => {
|
||||
const harness = stopHarness(async () => {
|
||||
throw new ExternalCliCommandError("unreachable", { code: "DAEMON_UNREACHABLE" });
|
||||
});
|
||||
|
||||
await expect(
|
||||
stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }),
|
||||
).rejects.toThrow("unreachable");
|
||||
expect(harness.stops).toEqual([{ keepTerminals: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,11 +37,7 @@ import {
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import {
|
||||
ExternalCliCommandError,
|
||||
runExternalCliJsonCommand,
|
||||
runExternalCliTextCommand,
|
||||
} from "./cli/external.js";
|
||||
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
|
||||
import {
|
||||
createDesktopSettingsCommandHandlers,
|
||||
type DesktopCommandHandler,
|
||||
@@ -352,84 +348,18 @@ export async function stopDesktopDaemon(
|
||||
"Daemon changed since confirmation; inspect its current home and PID before stopping it.",
|
||||
);
|
||||
if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus();
|
||||
const keepTerminals = keepsTerminalsOnStop(reason, process.platform);
|
||||
logDesktopDaemonLifecycle("stopping captured supervisor", {
|
||||
reason,
|
||||
pid: instance.pid,
|
||||
owned,
|
||||
keepTerminals,
|
||||
logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned });
|
||||
await stopDaemonInstance(home, {
|
||||
instance,
|
||||
timeoutMs: 15_000,
|
||||
requestShutdown: async (ready) => {
|
||||
await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]);
|
||||
},
|
||||
});
|
||||
await stopCapturedDaemon({ home, instance, reason, keepTerminals });
|
||||
if (owned) ownedLaunch = null;
|
||||
return resolveDesktopDaemonStatus();
|
||||
}
|
||||
|
||||
export interface StopCapturedDaemonInput {
|
||||
home: string;
|
||||
instance: DaemonInstance;
|
||||
reason: DesktopDaemonStopReason;
|
||||
keepTerminals: boolean;
|
||||
stopInstance?: typeof stopDaemonInstance;
|
||||
runCliJson?: typeof runExternalCliJsonCommand;
|
||||
log?: typeof logDesktopDaemonLifecycle;
|
||||
}
|
||||
|
||||
/** Stops the supervisor desktop captured, keeping its terminals when asked and supported. */
|
||||
export async function stopCapturedDaemon(input: StopCapturedDaemonInput): Promise<void> {
|
||||
const stopInstance = input.stopInstance ?? stopDaemonInstance;
|
||||
const runCliJson = input.runCliJson ?? runExternalCliJsonCommand;
|
||||
const logLifecycle = input.log ?? logDesktopDaemonLifecycle;
|
||||
const stop = (keep: boolean) =>
|
||||
stopInstance(input.home, {
|
||||
instance: input.instance,
|
||||
timeoutMs: 15_000,
|
||||
keepTerminals: keep,
|
||||
requestShutdown: async (ready) => {
|
||||
await runCliJson([
|
||||
"daemon",
|
||||
"stop",
|
||||
"--host",
|
||||
ready.listen,
|
||||
...(keep ? ["--keep-terminals"] : []),
|
||||
"--json",
|
||||
]);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await stop(input.keepTerminals);
|
||||
} catch (error) {
|
||||
// The CLI refuses a keep stop before sending anything when the daemon predates the feature;
|
||||
// that daemon closes terminals on every stop, as every upgrade did before.
|
||||
if (
|
||||
!input.keepTerminals ||
|
||||
!(error instanceof ExternalCliCommandError) ||
|
||||
error.code !== "TERMINALS_KEEP_UNSUPPORTED"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const version = typeof error.error?.version === "string" ? error.error.version : "unknown";
|
||||
logLifecycle(`daemon v${version} cannot keep terminals; stopping it and closing them`, {
|
||||
reason: input.reason,
|
||||
pid: input.instance.pid,
|
||||
});
|
||||
await stop(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a desktop stop keeps the daemon's terminals running for the daemon that replaces it.
|
||||
* Only when one comes straight back (an app update, a version-mismatch restart), and never on
|
||||
* Windows: a surviving terminal worker runs Paseo.exe and would lock the install folder the
|
||||
* update replaces. Quit, Settings, host removal and manual stops close terminals.
|
||||
*/
|
||||
export function keepsTerminalsOnStop(
|
||||
reason: DesktopDaemonStopReason,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
if (platform === "win32") return false;
|
||||
return reason === "app_update" || reason === "version_mismatch";
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||
await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]);
|
||||
return resolveDesktopDaemonStatus();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user