Archived
Merge pull request #57 from jmfederico/pr-36-generic-agent-config
feat: add Pi-compatible agent profiles
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add selectable Pi-compatible agent profiles and companion CLIs for isolated auth, models, settings, sessions, Pi packages, plugins, diagnostics, and safe update commands. Settings shows when a session-daemon restart is required, and mixed-version remote saves fail instead of reporting false success. The embedded runtime remains the bundled Pi SDK.
|
||||||
+98
-14
@@ -81,7 +81,7 @@
|
|||||||
<p>
|
<p>
|
||||||
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
|
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
|
||||||
trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
|
trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
|
||||||
manual upload defaults, upload limits, and session-daemon tools.
|
manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
<a href="#keys">Config matrix</a>
|
<a href="#keys">Config matrix</a>
|
||||||
<a href="#path-access">External path access</a>
|
<a href="#path-access">External path access</a>
|
||||||
<a href="#manual-uploads">Manual uploads</a>
|
<a href="#manual-uploads">Manual uploads</a>
|
||||||
|
<a href="#agent-runtime">Agent profile and companion CLI</a>
|
||||||
<a href="#session-tools">Session tools</a>
|
<a href="#session-tools">Session tools</a>
|
||||||
<a href="#completion-tools">Completion tools</a>
|
<a href="#completion-tools">Completion tools</a>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -112,8 +113,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
|
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
|
||||||
machine for config that affects work running there: session daemon tools, PI WEB plugin enablement,
|
machine for config that affects work running there: the Pi-compatible agent profile and companion CLI,
|
||||||
external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
|
session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
|
||||||
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
|
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
|
||||||
that do not advertise selected-machine settings support report those settings as unavailable instead of
|
that do not advertise selected-machine settings support report those settings as unavailable instead of
|
||||||
silently falling back to the gateway.
|
silently falling back to the gateway.
|
||||||
@@ -162,13 +163,15 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
|
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
|
||||||
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>,
|
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_AGENT_COMMAND</code>,
|
||||||
and <code>PI_WEB_SUBSESSIONS</code>.
|
<code>PI_WEB_AGENT_DIR</code>, <code>PI_WEB_AGENT_SESSION_DIR</code>, <code>PI_CODING_AGENT_DIR</code> /
|
||||||
|
<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility, <code>PI_WEB_SPAWN_SESSIONS</code>, and
|
||||||
|
<code>PI_WEB_SUBSESSIONS</code>.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
||||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
||||||
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
|
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
|
||||||
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
|
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
|
||||||
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
|
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
|
||||||
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
|
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
|
||||||
@@ -201,6 +204,10 @@
|
|||||||
"defaultFolder": ".pi-web/uploads"
|
"defaultFolder": ".pi-web/uploads"
|
||||||
},
|
},
|
||||||
"maxUploadBytes": 67108864,
|
"maxUploadBytes": 67108864,
|
||||||
|
"agent": {
|
||||||
|
"command": "pi",
|
||||||
|
"dir": "~/agent-profiles/research"
|
||||||
|
},
|
||||||
"spawnSessions": true,
|
"spawnSessions": true,
|
||||||
"subsessions": false,
|
"subsessions": false,
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -260,7 +267,7 @@
|
|||||||
it, and whether project-local config overrides or merges with global config. Rows with JSON key
|
it, and whether project-local config overrides or merges with global config. Rows with JSON key
|
||||||
<code>—</code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
|
<code>—</code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
|
||||||
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
|
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
|
||||||
<code>maxUploadBytes</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
|
<code>maxUploadBytes</code>, <code>agent</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
|
||||||
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
|
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
|
||||||
registry/tokens stay local.
|
registry/tokens stay local.
|
||||||
</p>
|
</p>
|
||||||
@@ -326,6 +333,22 @@
|
|||||||
<td>Not supported locally</td>
|
<td>Not supported locally</td>
|
||||||
<td>Restart web/API and session daemon on that machine</td>
|
<td>Restart web/API and session daemon on that machine</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Companion CLI command</td>
|
||||||
|
<td><code>agent.command</code></td>
|
||||||
|
<td><code>PI_WEB_AGENT_COMMAND</code></td>
|
||||||
|
<td>Global/session daemon</td>
|
||||||
|
<td>Not supported locally</td>
|
||||||
|
<td>Restart session daemon on that machine; affects doctor/status/update checks</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Agent profile state directory</td>
|
||||||
|
<td><code>agent.dir</code></td>
|
||||||
|
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
|
||||||
|
<td>Global/session daemon</td>
|
||||||
|
<td>Not supported locally</td>
|
||||||
|
<td>Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Agent can spawn sessions</td>
|
<td>Agent can spawn sessions</td>
|
||||||
<td><code>spawnSessions</code></td>
|
<td><code>spawnSessions</code></td>
|
||||||
@@ -432,18 +455,18 @@
|
|||||||
<td>Restart web/API; advanced state override</td>
|
<td>Restart web/API; advanced state override</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Pi session storage directory</td>
|
<td>Agent profile session storage directory</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td><code>PI_CODING_AGENT_SESSION_DIR</code></td>
|
<td><code>PI_WEB_AGENT_SESSION_DIR</code> (<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility)</td>
|
||||||
<td>Pi/session daemon env</td>
|
<td>Session daemon env</td>
|
||||||
<td>Not supported locally</td>
|
<td>Not supported locally</td>
|
||||||
<td>Restart session daemon; follows Pi session priority</td>
|
<td>Restart session daemon; env-only session storage override</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Pi agent config directory</td>
|
<td>Agent profile state directory</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td><code>PI_CODING_AGENT_DIR</code></td>
|
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
|
||||||
<td>Pi/Web/API/session daemon env</td>
|
<td>Web/API + session daemon env</td>
|
||||||
<td>Not supported locally</td>
|
<td>Not supported locally</td>
|
||||||
<td>Restart services</td>
|
<td>Restart services</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -513,6 +536,67 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="agent-runtime">
|
||||||
|
<h2>Pi-compatible agent profile and companion CLI</h2>
|
||||||
|
<p>
|
||||||
|
<code>agent.command</code> selects the Pi-compatible companion CLI used by <code>pi-web doctor</code> and,
|
||||||
|
when it can be generated safely, package-managed update commands. It defaults to <code>pi</code>. This
|
||||||
|
setting does <strong>not</strong> replace the embedded runtime: every session continues to use PI WEB's
|
||||||
|
bundled Pi SDK.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>agent.dir</code> selects the Pi-compatible state profile used for auth providers, models, settings,
|
||||||
|
sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to
|
||||||
|
<code>~/.pi/agent</code> only for a canonical Pi companion command. The directory must use the data layout
|
||||||
|
supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate
|
||||||
|
profile data, or repartition PI WEB-managed archives when the profile changes.
|
||||||
|
</p>
|
||||||
|
<div class="code-card">
|
||||||
|
<pre><code>{
|
||||||
|
"agent": {
|
||||||
|
"command": "pi-lab",
|
||||||
|
"dir": "/opt/pi-profiles/lab"
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
An alternate command always requires an explicit state directory. The command must be a safe bare
|
||||||
|
executable name such as <code>pi-lab</code> or a host-absolute executable path such as
|
||||||
|
<code>/opt/pi/bin/pi</code>; relative paths, shell expressions, and launcher strings are rejected. The
|
||||||
|
state directory must be host-absolute or start with <code>~</code>. In a federated save, the gateway
|
||||||
|
transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates
|
||||||
|
and returns the persisted profile.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Environment variables take precedence over the config file. <code>PI_WEB_AGENT_COMMAND</code> selects the
|
||||||
|
companion CLI, <code>PI_WEB_AGENT_DIR</code> sets the profile state directory, and
|
||||||
|
<code>PI_WEB_AGENT_SESSION_DIR</code> overrides session storage separately from <code>agent.dir</code>. The
|
||||||
|
legacy <code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code> names apply only to a
|
||||||
|
canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary
|
||||||
|
command. Use the explicit <code>PI_WEB_AGENT_*</code> names for alternate commands.
|
||||||
|
<code>PI_WEB_AGENT_DIR</code> is an unconditional override, while a legacy
|
||||||
|
<code>PI_CODING_AGENT_DIR</code> override stops applying when Settings selects an alternate command so the
|
||||||
|
command and directory can transition together.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The session daemon resolves the persisted desired values plus its environment once at startup. That
|
||||||
|
secret-free active profile stays fixed for the daemon lifetime. <strong>Settings → Session daemon</strong>
|
||||||
|
saves command and directory together as desired configuration and shows whether the profile is active,
|
||||||
|
needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations,
|
||||||
|
package-backed plugin discovery, status/install detection, and update planning continue to use the
|
||||||
|
daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the
|
||||||
|
newly saved values.
|
||||||
|
</p>
|
||||||
|
<div class="callout warning">
|
||||||
|
If the session daemon cannot report a valid active profile, profile-dependent package and plugin
|
||||||
|
operations report unavailable instead of falling back to independently resolved config. A package-managed
|
||||||
|
update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi
|
||||||
|
companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised
|
||||||
|
support, and the gateway rejects a remote save if the target does not return the requested profile.
|
||||||
|
Restart the session daemon on the selected machine to establish the next active profile.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="session-tools">
|
<section id="session-tools">
|
||||||
<h2>Session daemon tools</h2>
|
<h2>Session daemon tools</h2>
|
||||||
<h3><code>spawnSessions</code></h3>
|
<h3><code>spawnSessions</code></h3>
|
||||||
|
|||||||
+36
-7
@@ -1,6 +1,6 @@
|
|||||||
# PI WEB configuration reference
|
# PI WEB configuration reference
|
||||||
|
|
||||||
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools.
|
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
|
||||||
|
|
||||||
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
|
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ PI WEB uses two config files:
|
|||||||
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
|
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
|
||||||
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
|
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
|
||||||
|
|
||||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
|
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: the Pi-compatible agent profile and companion CLI, session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
|
||||||
|
|
||||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
|
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ defaults → global config file → environment overrides
|
|||||||
|
|
||||||
Supported project-local settings are then applied for that project's workspaces. For upload defaults, `<project>/.pi-web/config.json` overrides the global value.
|
Supported project-local settings are then applied for that project's workspaces. For upload defaults, `<project>/.pi-web/config.json` overrides the global value.
|
||||||
|
|
||||||
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
|
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for Pi compatibility, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
|
||||||
|
|
||||||
Process restarts depend on the key:
|
Process restarts depend on the key:
|
||||||
|
|
||||||
- `host` / `port`: restart the gateway web/API service or process.
|
- `host` / `port`: restart the gateway web/API service or process.
|
||||||
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
|
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
|
||||||
- `spawnSessions` / `subsessions`: restart the session daemon on that machine.
|
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine.
|
||||||
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
|
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
|
||||||
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
|
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
|
||||||
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
|
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
|
||||||
@@ -59,6 +59,10 @@ Process restarts depend on the key:
|
|||||||
"defaultFolder": ".pi-web/uploads"
|
"defaultFolder": ".pi-web/uploads"
|
||||||
},
|
},
|
||||||
"maxUploadBytes": 67108864,
|
"maxUploadBytes": 67108864,
|
||||||
|
"agent": {
|
||||||
|
"command": "pi",
|
||||||
|
"dir": "~/agent-profiles/research"
|
||||||
|
},
|
||||||
"spawnSessions": true,
|
"spawnSessions": true,
|
||||||
"subsessions": false,
|
"subsessions": false,
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -97,7 +101,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
|
|||||||
|
|
||||||
## Configuration matrix
|
## Configuration matrix
|
||||||
|
|
||||||
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
|
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
|
||||||
|
|
||||||
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
|
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
@@ -108,6 +112,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
|
|||||||
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
|
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
|
||||||
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
|
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
|
||||||
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
|
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
|
||||||
|
| Companion CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks |
|
||||||
|
| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins |
|
||||||
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
|
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
|
||||||
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
|
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
|
||||||
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
|
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
|
||||||
@@ -122,8 +128,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
|
|||||||
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
|
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
|
||||||
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
|
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
|
||||||
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
|
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
|
||||||
| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority |
|
| Agent profile session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override |
|
||||||
| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/Web/API/session daemon env | Not supported locally | Restart services |
|
| Agent profile state directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services |
|
||||||
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
|
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
|
||||||
|
|
||||||
## Key details
|
## Key details
|
||||||
@@ -177,6 +183,29 @@ For machine federation, Settings saves the global upload default on the selected
|
|||||||
|
|
||||||
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
|
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
|
||||||
|
|
||||||
|
### Pi-compatible agent profile and companion CLI
|
||||||
|
|
||||||
|
`agent.command` selects the Pi-compatible companion CLI used by `pi-web doctor` and, when it can be generated safely, package-managed update commands. It defaults to `pi`. This setting does **not** replace the embedded runtime: every session continues to use PI WEB's bundled Pi SDK.
|
||||||
|
|
||||||
|
`agent.dir` selects the Pi-compatible state profile used for auth providers, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to `~/.pi/agent` only for a canonical Pi companion command. The directory must use the data layout supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate profile data, or repartition PI WEB-managed archives when the profile changes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent": {
|
||||||
|
"command": "pi-lab",
|
||||||
|
"dir": "/opt/pi-profiles/lab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An alternate command always requires an explicit state directory. The command must be a safe bare executable name such as `pi-lab` or a host-absolute executable path such as `/opt/pi/bin/pi`; relative paths, shell expressions, and launcher strings are rejected. The state directory must be host-absolute or start with `~`. In a federated save, the gateway transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates and returns the persisted profile.
|
||||||
|
|
||||||
|
Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the companion CLI, `PI_WEB_AGENT_DIR` sets the profile state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. The legacy `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` names apply only to a canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary command. Use the explicit `PI_WEB_AGENT_*` names for alternate commands. `PI_WEB_AGENT_DIR` is an unconditional override, while a legacy `PI_CODING_AGENT_DIR` override stops applying when Settings selects an alternate command so the command and directory can transition together.
|
||||||
|
|
||||||
|
The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, package-backed plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values.
|
||||||
|
|
||||||
|
If the session daemon cannot report a valid active profile, profile-dependent package and plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile.
|
||||||
|
|
||||||
### Session daemon tools
|
### Session daemon tools
|
||||||
|
|
||||||
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
|
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
|
||||||
|
|||||||
+42
-3
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
agentCommandForChecks,
|
||||||
commandWithVersionCheck,
|
commandWithVersionCheck,
|
||||||
doctorExitCode,
|
doctorExitCode,
|
||||||
isCliEntrypoint,
|
isCliEntrypoint,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
} from "./cli.js";
|
} from "./cli.js";
|
||||||
|
|
||||||
const originalShell = process.env["SHELL"];
|
const originalShell = process.env["SHELL"];
|
||||||
|
const originalPiWebConfig = process.env["PI_WEB_CONFIG"];
|
||||||
|
const originalPiWebAgentCommand = process.env["PI_WEB_AGENT_COMMAND"];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (originalShell === undefined) {
|
if (originalShell === undefined) {
|
||||||
@@ -19,25 +22,61 @@ afterEach(() => {
|
|||||||
} else {
|
} else {
|
||||||
process.env["SHELL"] = originalShell;
|
process.env["SHELL"] = originalShell;
|
||||||
}
|
}
|
||||||
|
if (originalPiWebConfig === undefined) {
|
||||||
|
delete process.env["PI_WEB_CONFIG"];
|
||||||
|
} else {
|
||||||
|
process.env["PI_WEB_CONFIG"] = originalPiWebConfig;
|
||||||
|
}
|
||||||
|
if (originalPiWebAgentCommand === undefined) {
|
||||||
|
delete process.env["PI_WEB_AGENT_COMMAND"];
|
||||||
|
} else {
|
||||||
|
process.env["PI_WEB_AGENT_COMMAND"] = originalPiWebAgentCommand;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("commandWithVersionCheck", () => {
|
describe("commandWithVersionCheck", () => {
|
||||||
it("emits a POSIX subshell group for bash", () => {
|
it("emits a POSIX subshell group for bash", () => {
|
||||||
process.env["SHELL"] = "/bin/bash";
|
process.env["SHELL"] = "/bin/bash";
|
||||||
expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)");
|
expect(commandWithVersionCheck("npm")).toBe("command -v 'npm' && ('npm' --version 2>&1 || true)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits a POSIX subshell group for zsh", () => {
|
it("emits a POSIX subshell group for zsh", () => {
|
||||||
process.env["SHELL"] = "/bin/zsh";
|
process.env["SHELL"] = "/bin/zsh";
|
||||||
expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)");
|
expect(commandWithVersionCheck("pi")).toBe("command -v 'pi' && ('pi' --version 2>&1 || true)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses fish begin/end grouping instead of a POSIX subshell", () => {
|
it("uses fish begin/end grouping instead of a POSIX subshell", () => {
|
||||||
process.env["SHELL"] = "/usr/local/bin/fish";
|
process.env["SHELL"] = "/usr/local/bin/fish";
|
||||||
const command = commandWithVersionCheck("npm");
|
const command = commandWithVersionCheck("npm");
|
||||||
expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end");
|
expect(command).toBe("command -v 'npm' && begin; 'npm' --version 2>&1 || true; end");
|
||||||
expect(command).not.toContain("(");
|
expect(command).not.toContain("(");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shell-quotes command words", () => {
|
||||||
|
process.env["SHELL"] = "/bin/bash";
|
||||||
|
expect(commandWithVersionCheck("/tmp/agent's/acme-agent")).toBe("command -v '/tmp/agent'\\''s/acme-agent' && ('/tmp/agent'\\''s/acme-agent' --version 2>&1 || true)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agentCommandForChecks", () => {
|
||||||
|
it("reads the configured agent command for doctor checks", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
|
||||||
|
try {
|
||||||
|
const configPath = join(dir, "config.json");
|
||||||
|
writeFileSync(configPath, `${JSON.stringify({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } })}\n`);
|
||||||
|
process.env["PI_WEB_CONFIG"] = configPath;
|
||||||
|
delete process.env["PI_WEB_AGENT_COMMAND"];
|
||||||
|
|
||||||
|
expect(agentCommandForChecks()).toBe("acme-agent");
|
||||||
|
expect(agentCommandForChecks({
|
||||||
|
PI_WEB_CONFIG: configPath,
|
||||||
|
PI_WEB_AGENT_COMMAND: "environment-agent",
|
||||||
|
PI_WEB_AGENT_DIR: join(dir, "environment-agent-state"),
|
||||||
|
})).toBe("environment-agent");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("native-service doctor CLI contracts", () => {
|
describe("native-service doctor CLI contracts", () => {
|
||||||
|
|||||||
+12
-6
@@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
|
|||||||
import { homedir, userInfo } from "node:os";
|
import { homedir, userInfo } from "node:os";
|
||||||
import { basename, dirname, join, resolve } from "node:path";
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
|
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
|
||||||
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
||||||
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
|
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
|
||||||
import {
|
import {
|
||||||
@@ -153,7 +153,7 @@ function runQuiet(command: string, args: string[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasCommand(command: string): boolean {
|
function hasCommand(command: string): boolean {
|
||||||
return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0;
|
return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellSingleQuote(command)}`]).status === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLingerEnabled(): boolean | undefined {
|
function isLingerEnabled(): boolean | undefined {
|
||||||
@@ -760,15 +760,16 @@ function serviceShellLabel(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commandCheck(command: string): string {
|
function commandCheck(command: string): string {
|
||||||
return `command -v ${command}`;
|
return `command -v ${serviceShellQuote(command)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commandWithVersionCheck(command: string): string {
|
export function commandWithVersionCheck(command: string): string {
|
||||||
const found = commandCheck(command);
|
const found = commandCheck(command);
|
||||||
|
const commandWord = serviceShellQuote(command);
|
||||||
if (detectServiceShell().name === "fish") {
|
if (detectServiceShell().name === "fish") {
|
||||||
return `${found} && begin; ${command} --version 2>&1 || true; end`;
|
return `${found} && begin; ${commandWord} --version 2>&1 || true; end`;
|
||||||
}
|
}
|
||||||
return `${found} && (${command} --version 2>&1 || true)`;
|
return `${found} && (${commandWord} --version 2>&1 || true)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeVersionCheck(): string {
|
function nodeVersionCheck(): string {
|
||||||
@@ -778,12 +779,17 @@ function nodeVersionCheck(): string {
|
|||||||
].join(" && ");
|
].join(" && ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
return effectivePiWebConfig({ env }).config.agent.command;
|
||||||
|
}
|
||||||
|
|
||||||
function generalDoctorChecks(): Check[] {
|
function generalDoctorChecks(): Check[] {
|
||||||
const shell = serviceShellLabel();
|
const shell = serviceShellLabel();
|
||||||
|
const agentCommand = agentCommandForChecks();
|
||||||
return [
|
return [
|
||||||
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
||||||
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||||
[`Caller login ${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
|
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
|
|||||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||||
|
|||||||
@@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reads machine runtime through the gateway route", async () => {
|
it("reads machine runtime through the gateway route", async () => {
|
||||||
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] };
|
||||||
|
const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]);
|
||||||
|
|
||||||
await machinesApi.runtime("remote a");
|
await machinesApi.runtime("remote a");
|
||||||
|
await machinesApi.runtime("remote a", true);
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledOnce();
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
|
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
|
||||||
|
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1");
|
||||||
|
expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,7 +479,7 @@ function piWebConfigResponse(config: PiWebConfigValues) {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const machinesApi = {
|
|||||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||||
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||||
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||||
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
function configPath(machineId?: string): string {
|
function configPath(machineId?: string): string {
|
||||||
|
|||||||
@@ -1,34 +1,93 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||||
|
|
||||||
describe("API parsers", () => {
|
describe("API parsers", () => {
|
||||||
it("parses PI WEB config responses", () => {
|
it("parses PI WEB config responses", () => {
|
||||||
expect(parsePiWebConfigResponse({
|
expect(parsePiWebConfigResponse({
|
||||||
path: "/tmp/config.json",
|
path: "/tmp/config.json",
|
||||||
exists: true,
|
exists: true,
|
||||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
|
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
|
||||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
|
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
|
||||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
path: "/tmp/config.json",
|
path: "/tmp/config.json",
|
||||||
exists: true,
|
exists: true,
|
||||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
|
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
|
||||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
|
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
|
||||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses PI WEB runtime responses", () => {
|
it("parses PI WEB runtime responses including the daemon-owned active profile", () => {
|
||||||
expect(parsePiWebRuntimeResponse({
|
expect(parsePiWebRuntimeResponse({
|
||||||
packageName: "@jmfederico/pi-web",
|
packageName: "@jmfederico/pi-web",
|
||||||
generatedAt: "now",
|
generatedAt: "now",
|
||||||
components: {
|
components: {
|
||||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
sessiond: {
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
runtimeVersion: "1.0.0",
|
||||||
|
available: true,
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||||
|
activeAgentProfile: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "agent-lab",
|
||||||
|
dir: "/srv/agent-lab",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
})).toMatchObject({
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
|
||||||
|
components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => {
|
||||||
|
const profile = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"b".repeat(64)}`,
|
||||||
|
command: "C:\\tools\\pi.exe",
|
||||||
|
dir: "C:\\agent-profiles\\work",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
const components = {
|
||||||
|
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
|
||||||
|
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile },
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] });
|
||||||
|
|
||||||
|
expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir });
|
||||||
|
expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true);
|
||||||
|
expect(() => parseMachineRuntime({
|
||||||
|
machineId: "remote-a",
|
||||||
|
ok: true,
|
||||||
|
checkedAt: "now",
|
||||||
|
components: { ...components, web: { ...components.web, activeAgentProfile: profile } },
|
||||||
|
capabilities: [],
|
||||||
|
})).toThrow("Invalid active agent profile descriptor");
|
||||||
|
expect(() => parseMachineRuntime({
|
||||||
|
machineId: "remote-a",
|
||||||
|
ok: true,
|
||||||
|
checkedAt: "now",
|
||||||
|
components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } },
|
||||||
|
capabilities: [],
|
||||||
|
})).toThrow("Invalid active agent profile descriptor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed agent directory override metadata", () => {
|
||||||
|
expect(() => parsePiWebConfigResponse({
|
||||||
|
path: "/tmp/config.json",
|
||||||
|
exists: true,
|
||||||
|
config: {},
|
||||||
|
effectiveConfig: {},
|
||||||
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" },
|
||||||
|
})).toThrow("Invalid PI WEB agentDirSource field");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses Pi package list and mutation responses", () => {
|
it("parses Pi package list and mutation responses", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||||
|
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -562,11 +563,21 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
|
|||||||
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
|
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
|
||||||
...optionalField("uploads", optionalUploads(record["uploads"])),
|
...optionalField("uploads", optionalUploads(record["uploads"])),
|
||||||
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
|
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
|
||||||
|
...optionalField("agent", optionalAgent(record["agent"])),
|
||||||
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
|
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
|
||||||
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
|
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalAgent(value: unknown): PiWebConfigValues["agent"] | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB agent field");
|
||||||
|
return {
|
||||||
|
...optionalField("command", optionalString(value, "command")),
|
||||||
|
...optionalField("dir", optionalString(value, "dir")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
|
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (value === true) return true;
|
if (value === true) return true;
|
||||||
@@ -629,7 +640,24 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
|
|||||||
|
|
||||||
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
|
return {
|
||||||
|
host: requireBoolean(record, "host"),
|
||||||
|
port: requireBoolean(record, "port"),
|
||||||
|
allowedHosts: requireBoolean(record, "allowedHosts"),
|
||||||
|
spawnSessions: requireBoolean(record, "spawnSessions"),
|
||||||
|
subsessions: requireBoolean(record, "subsessions"),
|
||||||
|
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
|
||||||
|
agentDir: optionalBoolean(record, "agentDir") ?? false,
|
||||||
|
...optionalAgentDirSource(record),
|
||||||
|
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalAgentDirSource(record: Record<string, unknown>): { agentDirSource?: PiWebAgentDirEnvSource } {
|
||||||
|
const value = record["agentDirSource"];
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field");
|
||||||
|
return { agentDirSource: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
|
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
|
||||||
@@ -733,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp
|
|||||||
|
|
||||||
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
|
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
|
const component = parsePiWebServiceComponent(record["component"]);
|
||||||
|
const activeAgentProfileValue = record["activeAgentProfile"];
|
||||||
|
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
|
||||||
|
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor");
|
||||||
return {
|
return {
|
||||||
component: parsePiWebServiceComponent(record["component"]),
|
component,
|
||||||
label: requireString(record, "label"),
|
label: requireString(record, "label"),
|
||||||
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
|
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
|
||||||
available: requireBoolean(record, "available"),
|
available: requireBoolean(record, "available"),
|
||||||
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
||||||
|
...optionalField("activeAgentProfile", activeAgentProfile),
|
||||||
...optionalField("error", optionalString(record, "error")),
|
...optionalField("error", optionalString(record, "error")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
|
|||||||
case "method": return html`
|
case "method": return html`
|
||||||
<div class="options">
|
<div class="options">
|
||||||
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
|
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
|
||||||
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
|
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
|
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
|
||||||
case "apiKey": return html`
|
case "apiKey": return html`
|
||||||
<div class="form">
|
<div class="form">
|
||||||
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
|
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the active Pi-compatible profile's <code>auth.json</code>.</p>
|
||||||
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
|
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
|
||||||
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
|
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
|
||||||
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
|
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
|
||||||
|
|||||||
@@ -67,4 +67,3 @@ export class FormattedText extends LitElement {
|
|||||||
|
|
||||||
static override styles = formattedTextStyles;
|
static override styles = formattedTextStyles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1962,7 +1962,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||||
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
||||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||||
import { SettingsDialog } from "./SettingsDialog";
|
import { SettingsDialog } from "./SettingsDialog";
|
||||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||||
@@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => {
|
|||||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reloads desired config and the active runtime descriptor together", async () => {
|
||||||
|
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
|
||||||
|
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||||
|
const runtimeRefresh = vi.fn(() => Promise.resolve());
|
||||||
|
const dialog = new SettingsDialog();
|
||||||
|
dialog.machine = remoteMachine;
|
||||||
|
dialog.onRefreshMachineRuntime = runtimeRefresh;
|
||||||
|
|
||||||
|
await callDialogPromise(dialog, "reloadSessiondState");
|
||||||
|
|
||||||
|
expect(configSpy).toHaveBeenCalledWith(remoteMachine.id);
|
||||||
|
expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id);
|
||||||
|
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||||
|
});
|
||||||
|
|
||||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||||
stubWindowTimers();
|
stubWindowTimers();
|
||||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||||
@@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => {
|
|||||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails closed for a remote agent-profile save without granular support", async () => {
|
||||||
|
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
|
||||||
|
const dialog = new SettingsDialog();
|
||||||
|
dialog.machine = remoteMachine;
|
||||||
|
dialog.machineRuntime = {
|
||||||
|
machineId: remoteMachine.id,
|
||||||
|
ok: true,
|
||||||
|
checkedAt: "now",
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings],
|
||||||
|
};
|
||||||
|
|
||||||
|
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
|
||||||
|
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getDialogProperty(dialog, "sessiondError")).toBe("Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a remote agent profile when granular support is advertised", async () => {
|
||||||
|
stubWindowTimers();
|
||||||
|
const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } };
|
||||||
|
const saved = configResponse(patch);
|
||||||
|
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved);
|
||||||
|
const dialog = new SettingsDialog();
|
||||||
|
dialog.machine = remoteMachine;
|
||||||
|
dialog.machineRuntime = {
|
||||||
|
machineId: remoteMachine.id,
|
||||||
|
ok: true,
|
||||||
|
checkedAt: "now",
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||||
|
};
|
||||||
|
|
||||||
|
await callDialogPromise(dialog, "saveSessiondConfig", patch);
|
||||||
|
|
||||||
|
expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id);
|
||||||
|
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||||
const load = deferred<PiWebConfigResponse>();
|
const load = deferred<PiWebConfigResponse>();
|
||||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel";
|
|||||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||||
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
||||||
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
|
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
|
||||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
|
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
|
||||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
|
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
|
||||||
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
|
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement {
|
|||||||
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
||||||
@property({ attribute: false }) onClose?: () => void;
|
@property({ attribute: false }) onClose?: () => void;
|
||||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||||
|
@property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise<void>;
|
||||||
@state() private configResponse: PiWebConfigResponse | undefined;
|
@state() private configResponse: PiWebConfigResponse | undefined;
|
||||||
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
|
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
|
||||||
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
|
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
|
||||||
@@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
void this.loadConfig();
|
void this.loadConfig();
|
||||||
void this.loadAccessConfigForTarget();
|
void this.loadAccessConfigForTarget();
|
||||||
void this.loadSessiondConfigForTarget();
|
void this.reloadSessiondState();
|
||||||
void this.loadPluginsForTarget();
|
void this.loadPluginsForTarget();
|
||||||
void this.loadPackagesForTarget();
|
void this.loadPackagesForTarget();
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement {
|
|||||||
.error=${this.sessiondError}
|
.error=${this.sessiondError}
|
||||||
.savedMessage=${this.savedMessage}
|
.savedMessage=${this.savedMessage}
|
||||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||||
.onReload=${() => this.loadSessiondConfigForTarget()}
|
.activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile}
|
||||||
|
.agentProfileSupport=${this.agentProfileSettingsSupport()}
|
||||||
|
.onReload=${() => this.reloadSessiondState()}
|
||||||
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
|
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
|
||||||
></settings-sessiond-panel>
|
></settings-sessiond-panel>
|
||||||
`;
|
`;
|
||||||
@@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async reloadSessiondState(target = this.settingsTarget()): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadSessiondConfigForTarget(target),
|
||||||
|
this.onRefreshMachineRuntime?.(target.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||||
const requestSeq = ++this.sessiondLoadRequestSeq;
|
const requestSeq = ++this.sessiondLoadRequestSeq;
|
||||||
const support = this.selectedMachineSettingsSupport(target);
|
const support = this.selectedMachineSettingsSupport(target);
|
||||||
@@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement {
|
|||||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (config.agent !== undefined) {
|
||||||
|
const profileSupport = this.agentProfileSettingsSupport(target);
|
||||||
|
if (!isAgentProfileSettingsSupported(profileSupport)) {
|
||||||
|
this.sessiondError = profileSupport.message ?? `Pi-compatible agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.sessiondError = "";
|
this.sessiondError = "";
|
||||||
this.savedMessage = "";
|
this.savedMessage = "";
|
||||||
@@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement {
|
|||||||
return selectedMachineSettingsSupport(target, this.machineRuntime);
|
return selectedMachineSettingsSupport(target, this.machineRuntime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport {
|
||||||
|
return agentProfileSettingsSupport(target, this.machineRuntime);
|
||||||
|
}
|
||||||
|
|
||||||
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
|
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
|
||||||
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
|
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
|
||||||
const currentSupport = this.selectedMachineSettingsSupport(target);
|
const currentSupport = this.selectedMachineSettingsSupport(target);
|
||||||
|
|||||||
@@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||||
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
|
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
|
||||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||||
|
|
||||||
@@ -8,24 +8,34 @@ describe("settings-sessiond-panel layout", () => {
|
|||||||
it("names the selected machine in the scope and restart notice when config is available", () => {
|
it("names the selected machine in the scope and restart notice when config is available", () => {
|
||||||
const panel = new SettingsSessiondPanel();
|
const panel = new SettingsSessiondPanel();
|
||||||
panel.targetLabel = "Lab Mac (remote machine)";
|
panel.targetLabel = "Lab Mac (remote machine)";
|
||||||
panel.configResponse = configResponse({ spawnSessions: true, subsessions: false });
|
setPanelConfig(panel, configResponse({
|
||||||
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
|
spawnSessions: true,
|
||||||
|
subsessions: false,
|
||||||
|
}));
|
||||||
|
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
|
||||||
|
|
||||||
const rendered = flattenTemplateContent(panel.render());
|
const rendered = flattenTemplateContent(panel.render());
|
||||||
|
|
||||||
expectTextOrder(rendered, [
|
expectTextOrder(rendered, [
|
||||||
"Session daemon",
|
"Session daemon",
|
||||||
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
|
"Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
|
||||||
"Reload",
|
"Reload",
|
||||||
"Restart required on Lab Mac (remote machine)",
|
"Pi-compatible agent profile restart required on Lab Mac (remote machine)",
|
||||||
"run <code>pi-web restart</code> on that machine",
|
"Run <code>pi-web restart</code> on that machine",
|
||||||
"Config file",
|
"Config file",
|
||||||
|
"Companion CLI command",
|
||||||
|
"agent-lab",
|
||||||
|
"Profile state directory",
|
||||||
|
"/srv/agent-lab",
|
||||||
"Allow agents to start sessions",
|
"Allow agents to start sessions",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders save/load notices before the restart notice and settings content", () => {
|
it("orders save/load notices before the restart notice and settings content", () => {
|
||||||
const panel = new SettingsSessiondPanel();
|
const panel = new SettingsSessiondPanel();
|
||||||
panel.configResponse = configResponse({ spawnSessions: false });
|
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
|
||||||
|
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
|
||||||
panel.error = "Failed to save session-daemon config.";
|
panel.error = "Failed to save session-daemon config.";
|
||||||
panel.savedMessage = "Session daemon settings saved.";
|
panel.savedMessage = "Session daemon settings saved.";
|
||||||
|
|
||||||
@@ -34,11 +44,55 @@ describe("settings-sessiond-panel layout", () => {
|
|||||||
expectTextOrder(rendered, [
|
expectTextOrder(rendered, [
|
||||||
"Failed to save session-daemon config.",
|
"Failed to save session-daemon config.",
|
||||||
"Session daemon settings saved.",
|
"Session daemon settings saved.",
|
||||||
"Restart required on local (local gateway)",
|
"Pi-compatible agent profile restart required on local (local gateway)",
|
||||||
"Config file",
|
"Config file",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the profile as active without restart guidance when desired and active match", () => {
|
||||||
|
const panel = new SettingsSessiondPanel();
|
||||||
|
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
|
||||||
|
panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
|
||||||
|
|
||||||
|
const rendered = flattenTemplateContent(panel.render());
|
||||||
|
|
||||||
|
expect(rendered).toContain("Profile status");
|
||||||
|
expect(rendered).toContain("Active");
|
||||||
|
expect(rendered).not.toContain("restart required on");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits command and directory together as one profile save", async () => {
|
||||||
|
const panel = new SettingsSessiondPanel();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } }));
|
||||||
|
setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " });
|
||||||
|
panel.onSave = onSave;
|
||||||
|
const event = new Event("submit", { cancelable: true });
|
||||||
|
|
||||||
|
await callPanelPromise(panel, "saveAgentProfile", event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => {
|
||||||
|
const panel = new SettingsSessiondPanel();
|
||||||
|
const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false });
|
||||||
|
setPanelConfig(panel, initial);
|
||||||
|
callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" });
|
||||||
|
|
||||||
|
const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true });
|
||||||
|
panel.configResponse = toggled;
|
||||||
|
callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]]));
|
||||||
|
|
||||||
|
expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" });
|
||||||
|
|
||||||
|
const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true });
|
||||||
|
panel.configResponse = saved;
|
||||||
|
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
|
||||||
|
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
|
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
|
||||||
const panel = new SettingsSessiondPanel();
|
const panel = new SettingsSessiondPanel();
|
||||||
panel.targetLabel = "Lab Mac (remote machine)";
|
panel.targetLabel = "Lab Mac (remote machine)";
|
||||||
@@ -57,6 +111,37 @@ describe("settings-sessiond-panel layout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void {
|
||||||
|
panel.configResponse = config;
|
||||||
|
callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void {
|
||||||
|
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||||
|
const result = callPanelMethod(panel, methodName, ...args);
|
||||||
|
if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`);
|
||||||
|
await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown {
|
||||||
|
const method: unknown = Reflect.get(panel, methodName);
|
||||||
|
if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`);
|
||||||
|
return Reflect.apply(method, panel, args);
|
||||||
|
}
|
||||||
|
|
||||||
function flattenTemplateContent(template: TemplateResult): string {
|
function flattenTemplateContent(template: TemplateResult): string {
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
visitTemplate(template);
|
visitTemplate(template);
|
||||||
@@ -138,6 +223,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||||
import "./SettingsPanelFrame";
|
import "./SettingsPanelFrame";
|
||||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||||
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft";
|
||||||
|
import type { AgentProfileSettingsSupport } from "./settingsMachineTarget";
|
||||||
|
import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||||
|
|
||||||
@customElement("settings-sessiond-panel")
|
@customElement("settings-sessiond-panel")
|
||||||
export class SettingsSessiondPanel extends LitElement {
|
export class SettingsSessiondPanel extends LitElement {
|
||||||
@@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
@property() error = "";
|
@property() error = "";
|
||||||
@property() savedMessage = "";
|
@property() savedMessage = "";
|
||||||
@property() targetLabel = "local (local gateway)";
|
@property() targetLabel = "local (local gateway)";
|
||||||
|
@property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
|
||||||
|
@property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" };
|
||||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||||
|
@state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft();
|
||||||
|
@state() private agentDraftDirty = false;
|
||||||
|
@state() private agentLocalError = "";
|
||||||
|
|
||||||
|
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||||
|
if (!changed.has("configResponse")) return;
|
||||||
|
if (this.configResponse === undefined) {
|
||||||
|
this.agentDraft = emptyAgentProfileConfigDraft();
|
||||||
|
this.agentDraftDirty = false;
|
||||||
|
this.agentLocalError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) {
|
||||||
|
this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config);
|
||||||
|
this.agentDraftDirty = false;
|
||||||
|
this.agentLocalError = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override render(): TemplateResult {
|
override render(): TemplateResult {
|
||||||
const config = this.configResponse;
|
const config = this.configResponse;
|
||||||
@@ -25,6 +47,13 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
const subsessionsOverridden = config?.envOverrides.subsessions === true;
|
const subsessionsOverridden = config?.envOverrides.subsessions === true;
|
||||||
// Beta, off by default; also requires spawn to be enabled.
|
// Beta, off by default; also requires spawn to be enabled.
|
||||||
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
|
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
|
||||||
|
const agentCommandOverridden = config?.envOverrides.agentCommand === true;
|
||||||
|
const profileEditingSupported = this.agentProfileSupport.state === "supported";
|
||||||
|
const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command;
|
||||||
|
const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand);
|
||||||
|
const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true;
|
||||||
|
const effectiveAgent = config?.effectiveConfig.agent;
|
||||||
|
const profileActivation = agentProfileActivationState(config, this.activeAgentProfile);
|
||||||
return html`
|
return html`
|
||||||
<settings-panel-frame
|
<settings-panel-frame
|
||||||
heading="Session daemon"
|
heading="Session daemon"
|
||||||
@@ -39,6 +68,46 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
<span>Config file</span>
|
<span>Config file</span>
|
||||||
<code>${config.path}</code>
|
<code>${config.path}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
|
||||||
|
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Pi-compatible agent profile editing is unavailable for this machine."}</div>`}
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-heading">
|
||||||
|
<span>Companion CLI command</span>
|
||||||
|
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
.value=${this.agentDraft.command}
|
||||||
|
placeholder="pi"
|
||||||
|
?disabled=${this.loading || this.saving || !profileEditingSupported || agentCommandOverridden}
|
||||||
|
@input=${(event: Event) => { this.updateAgentDraft({ command: inputValue(event) }); }}
|
||||||
|
>
|
||||||
|
<small>Set the Pi-compatible companion CLI used for doctor and update checks. The embedded session runtime remains PI WEB's bundled Pi SDK.</small>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-heading">
|
||||||
|
<span>Profile state directory</span>
|
||||||
|
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
.value=${this.agentDraft.dir}
|
||||||
|
placeholder="~/.pi/agent or ~/agent-profiles/work"
|
||||||
|
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
|
||||||
|
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
|
||||||
|
>
|
||||||
|
<small>Choose the Pi-compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
|
||||||
|
</label>
|
||||||
|
<footer class="form-actions">
|
||||||
|
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-heading">
|
<span class="field-heading">
|
||||||
<span>Allow agents to start sessions</span>
|
<span>Allow agents to start sessions</span>
|
||||||
@@ -72,9 +141,14 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
</label>
|
</label>
|
||||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||||
</div>
|
</div>
|
||||||
<section class="effective-card" aria-label="Effective configuration summary">
|
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
|
||||||
<h3>Effective after environment overrides</h3>
|
<h3>Desired after environment overrides</h3>
|
||||||
<dl>
|
<dl>
|
||||||
|
<div><dt>Desired command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||||
|
<div><dt>Desired state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||||
|
<div><dt>Active command</dt><dd>${this.activeAgentProfile?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||||
|
<div><dt>Active state</dt><dd>${this.activeAgentProfile?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
|
||||||
|
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</dd></div>
|
||||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -86,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
|
|
||||||
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
|
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
|
||||||
const notices: SettingsNotice[] = [];
|
const notices: SettingsNotice[] = [];
|
||||||
if (this.error !== "") notices.push({ type: "error", content: this.error });
|
const error = this.agentLocalError || this.error;
|
||||||
|
if (error !== "") notices.push({ type: "error", content: error });
|
||||||
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
|
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
|
||||||
if (config !== undefined) {
|
const activation = agentProfileActivationState(config, this.activeAgentProfile);
|
||||||
|
if (activation === "restart-required") {
|
||||||
notices.push({
|
notices.push({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
title: `Restart required on ${this.targetLabel}`,
|
title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
|
||||||
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
|
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
|
||||||
|
});
|
||||||
|
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
|
||||||
|
notices.push({
|
||||||
|
type: "info",
|
||||||
|
title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
|
||||||
|
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return notices;
|
return notices;
|
||||||
@@ -102,6 +184,22 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
|
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async saveAgentProfile(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
this.agentLocalError = "";
|
||||||
|
try {
|
||||||
|
await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft));
|
||||||
|
} catch (error) {
|
||||||
|
this.agentLocalError = errorMessage(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAgentDraft(patch: Partial<AgentProfileConfigDraft>): void {
|
||||||
|
this.agentDraft = { ...this.agentDraft, ...patch };
|
||||||
|
this.agentDraftDirty = true;
|
||||||
|
this.agentLocalError = "";
|
||||||
|
}
|
||||||
|
|
||||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||||
await this.onSave?.(spawnSessionsConfigPatch(enabled));
|
await this.onSave?.(spawnSessionsConfigPatch(enabled));
|
||||||
@@ -118,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
button, input { font: inherit; }
|
button, input { font: inherit; }
|
||||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
.loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
.loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||||
.loading-card { color: var(--pi-muted); }
|
.loading-card { color: var(--pi-muted); }
|
||||||
.config-path-card { display: grid; gap: 5px; }
|
.config-path-card { display: grid; gap: 5px; }
|
||||||
|
.profile-form { display: grid; gap: 14px; }
|
||||||
|
.profile-support-message { color: var(--pi-muted); line-height: 1.45; }
|
||||||
|
.form-actions { display: flex; justify-content: flex-end; }
|
||||||
|
.primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); }
|
||||||
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||||
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
||||||
.field { display: grid; gap: 7px; }
|
.field { display: grid; gap: 7px; }
|
||||||
@@ -128,6 +230,20 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
.field-heading { display: flex; align-items: center; gap: 8px; }
|
.field-heading { display: flex; align-items: center; gap: 8px; }
|
||||||
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
|
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
|
||||||
.toggle input { width: 16px; height: 16px; }
|
.toggle input { width: 16px; height: 16px; }
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--pi-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--pi-bg);
|
||||||
|
color: var(--pi-text);
|
||||||
|
padding: 8px 9px;
|
||||||
|
outline: none;
|
||||||
|
font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||||
|
}
|
||||||
|
.text-input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
|
||||||
|
.text-input:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
.toggle input:disabled { cursor: not-allowed; }
|
.toggle input:disabled { cursor: not-allowed; }
|
||||||
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
|
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
|
||||||
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
@@ -143,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileActivationLabel(state: ReturnType<typeof agentProfileActivationState>): string | TemplateResult {
|
||||||
|
if (state === "active") return "Active";
|
||||||
|
if (state === "restart-required") return "Restart required";
|
||||||
|
return html`<span class="muted">Unavailable</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputValue(event: Event): string {
|
||||||
|
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
function sessiondDescription(targetLabel: string): string {
|
function sessiondDescription(targetLabel: string): string {
|
||||||
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
|
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
agentProfileConfigPatchFromDraft,
|
||||||
|
agentProfileDraftFromConfig,
|
||||||
|
agentProfileDraftMatchesConfig,
|
||||||
gatewayServerConfigFromDraft,
|
gatewayServerConfigFromDraft,
|
||||||
gatewayServerDraftFromConfig,
|
gatewayServerDraftFromConfig,
|
||||||
machineAccessConfigPatchFromDraft,
|
machineAccessConfigPatchFromDraft,
|
||||||
@@ -29,6 +32,22 @@ describe("settings config drafts", () => {
|
|||||||
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds one atomic agent profile patch from both draft fields", () => {
|
||||||
|
expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({
|
||||||
|
command: "agent-lab",
|
||||||
|
dir: "/srv/agent-lab",
|
||||||
|
});
|
||||||
|
expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({
|
||||||
|
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
|
||||||
|
});
|
||||||
|
expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} });
|
||||||
|
expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({
|
||||||
|
agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" },
|
||||||
|
});
|
||||||
|
expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true);
|
||||||
|
expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("builds gateway server saves without dropping preserved config values", () => {
|
it("builds gateway server saves without dropping preserved config values", () => {
|
||||||
expect(gatewayServerConfigFromDraft({
|
expect(gatewayServerConfigFromDraft({
|
||||||
host: " gateway.local ",
|
host: " gateway.local ",
|
||||||
@@ -43,6 +62,7 @@ describe("settings config drafts", () => {
|
|||||||
maxUploadBytes: 1234,
|
maxUploadBytes: 1234,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
host: "gateway.local",
|
host: "gateway.local",
|
||||||
port: 9000,
|
port: 9000,
|
||||||
@@ -54,6 +74,7 @@ describe("settings config drafts", () => {
|
|||||||
maxUploadBytes: 1234,
|
maxUploadBytes: 1234,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(gatewayServerConfigFromDraft({
|
expect(gatewayServerConfigFromDraft({
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft {
|
|||||||
uploadDefaultFolder: string;
|
uploadDefaultFolder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentProfileConfigDraft {
|
||||||
|
command: string;
|
||||||
|
dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
||||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||||
}
|
}
|
||||||
@@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
|
|||||||
return { allowedPathsText: "", uploadDefaultFolder: "" };
|
return { allowedPathsText: "", uploadDefaultFolder: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft {
|
||||||
|
return { command: "", dir: "" };
|
||||||
|
}
|
||||||
|
|
||||||
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
|
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
|
||||||
return {
|
return {
|
||||||
host: config.host ?? "",
|
host: config.host ?? "",
|
||||||
@@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft {
|
||||||
|
return {
|
||||||
|
command: config.agent?.command ?? "",
|
||||||
|
dir: config.agent?.dir ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues {
|
||||||
|
const command = draft.command.trim();
|
||||||
|
const dir = draft.dir.trim();
|
||||||
|
return {
|
||||||
|
agent: {
|
||||||
|
...(command === "" ? {} : { command }),
|
||||||
|
...(dir === "" ? {} : { dir }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean {
|
||||||
|
const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {};
|
||||||
|
const configured = config.agent ?? {};
|
||||||
|
return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir;
|
||||||
|
}
|
||||||
|
|
||||||
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||||
const config = preservedGatewayConfigRemainder(baseConfig);
|
const config = preservedGatewayConfigRemainder(baseConfig);
|
||||||
const host = draft.host.trim();
|
const host = draft.host.trim();
|
||||||
@@ -68,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo
|
|||||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||||
|
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config: { host: "127.0.0.1" },
|
config: { host: "127.0.0.1" },
|
||||||
effectiveConfig: { host: "127.0.0.1" },
|
effectiveConfig: { host: "127.0.0.1" },
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
|
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
|
||||||
|
|||||||
@@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Machine, MachineRuntime } from "../../api";
|
import type { Machine, MachineRuntime } from "../../api";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
|
||||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
|
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
|
||||||
|
|
||||||
const remoteMachine: Machine = {
|
const remoteMachine: Machine = {
|
||||||
id: "remote-a",
|
id: "remote-a",
|
||||||
@@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => {
|
|||||||
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
|
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gates remote agent profile edits on their granular capability", () => {
|
||||||
|
const target = settingsMachineTarget(remoteMachine);
|
||||||
|
|
||||||
|
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
|
||||||
|
expect(agentProfileSettingsSupport(target, undefined)).toEqual({
|
||||||
|
state: "unknown",
|
||||||
|
message: "Pi-compatible agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
|
||||||
|
});
|
||||||
|
expect(agentProfileSettingsSupport(target, {
|
||||||
|
ok: true,
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||||
|
})).toEqual({ state: "supported" });
|
||||||
|
|
||||||
|
const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] });
|
||||||
|
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
|
||||||
|
expect(unsupported).toEqual({
|
||||||
|
state: "unsupported",
|
||||||
|
message: "Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
|
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
|
||||||
const target = settingsMachineTarget(remoteMachine);
|
const target = settingsMachineTarget(remoteMachine);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport;
|
||||||
|
|
||||||
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
|
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
|
||||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||||
return { id: "local", name: "local", kind: "local" };
|
return { id: "local", name: "local", kind: "local" };
|
||||||
@@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru
|
|||||||
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
|
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): AgentProfileSettingsSupport {
|
||||||
|
if (target.kind === "local") return { state: "supported" };
|
||||||
|
if (runtime?.ok !== true) {
|
||||||
|
return {
|
||||||
|
state: "unknown",
|
||||||
|
message: `Pi-compatible agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
|
||||||
|
return {
|
||||||
|
state: "unsupported",
|
||||||
|
message: `Pi-compatible agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
|
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
|
||||||
return `${support.state}:${support.message ?? ""}`;
|
return `${support.state}:${support.message ?? ""}`;
|
||||||
}
|
}
|
||||||
@@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet
|
|||||||
return support?.state === "unsupported";
|
return support?.state === "unsupported";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean {
|
||||||
|
return support?.state === "supported";
|
||||||
|
}
|
||||||
|
|
||||||
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
|
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
|
||||||
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
|
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
|||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||||
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||||
|
|
||||||
describe("session daemon settings config helpers", () => {
|
describe("session daemon settings config helpers", () => {
|
||||||
it("builds daemon-only save patches for the sessiond toggles", () => {
|
it("builds daemon-only save patches for the sessiond toggles", () => {
|
||||||
@@ -8,6 +8,39 @@ describe("session daemon settings config helpers", () => {
|
|||||||
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compares the desired effective profile with the daemon-owned active profile", () => {
|
||||||
|
const config = configResponse(
|
||||||
|
{ agent: { command: "configured-agent", dir: "/configured" } },
|
||||||
|
{},
|
||||||
|
{ agent: { command: "effective-agent", dir: "/effective" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active");
|
||||||
|
expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required");
|
||||||
|
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required");
|
||||||
|
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required");
|
||||||
|
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active");
|
||||||
|
expect(agentProfileActivationState(config, undefined)).toBe("unavailable");
|
||||||
|
expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => {
|
||||||
|
const baseOverrides = configResponse({}).envOverrides;
|
||||||
|
|
||||||
|
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true);
|
||||||
|
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true);
|
||||||
|
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false);
|
||||||
|
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true);
|
||||||
|
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak the gateway agent directory source into a selected-machine response", () => {
|
||||||
|
const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" });
|
||||||
|
const selectedMachine = configResponse({}, { agentDir: false });
|
||||||
|
|
||||||
|
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
||||||
const gateway = configResponse({
|
const gateway = configResponse({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -17,8 +50,13 @@ describe("session daemon settings config helpers", () => {
|
|||||||
plugins: { info: { enabled: true } },
|
plugins: { info: { enabled: true } },
|
||||||
spawnSessions: false,
|
spawnSessions: false,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "gateway-agent", dir: "/srv/gateway-agent" },
|
||||||
});
|
});
|
||||||
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
|
const selectedMachine = configResponse(
|
||||||
|
{ spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } },
|
||||||
|
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true },
|
||||||
|
{ spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } },
|
||||||
|
);
|
||||||
|
|
||||||
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
|
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
|
||||||
...gateway,
|
...gateway,
|
||||||
@@ -30,6 +68,7 @@ describe("session daemon settings config helpers", () => {
|
|||||||
plugins: { info: { enabled: true } },
|
plugins: { info: { enabled: true } },
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: true,
|
subsessions: true,
|
||||||
|
agent: { command: "machine-agent", dir: "/srv/machine-agent" },
|
||||||
},
|
},
|
||||||
effectiveConfig: {
|
effectiveConfig: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -39,6 +78,7 @@ describe("session daemon settings config helpers", () => {
|
|||||||
plugins: { info: { enabled: true } },
|
plugins: { info: { enabled: true } },
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: true,
|
subsessions: true,
|
||||||
|
agent: { command: "env-agent", dir: "/srv/machine-agent" },
|
||||||
},
|
},
|
||||||
envOverrides: {
|
envOverrides: {
|
||||||
host: false,
|
host: false,
|
||||||
@@ -46,17 +86,45 @@ describe("session daemon settings config helpers", () => {
|
|||||||
allowedHosts: false,
|
allowedHosts: false,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agentCommand: true,
|
||||||
|
agentDir: false,
|
||||||
|
agentDirSource: "pi-compatibility",
|
||||||
|
agentSessionDir: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
|
function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configResponse(
|
||||||
|
config: PiWebConfigValues,
|
||||||
|
overrides: Partial<PiWebConfigResponse["envOverrides"]> = {},
|
||||||
|
effectiveConfig: PiWebConfigValues = config,
|
||||||
|
): PiWebConfigResponse {
|
||||||
return {
|
return {
|
||||||
path: "/tmp/pi-web/config.json",
|
path: "/tmp/pi-web/config.json",
|
||||||
exists: true,
|
exists: true,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
|
envOverrides: {
|
||||||
|
host: false,
|
||||||
|
port: false,
|
||||||
|
allowedHosts: false,
|
||||||
|
spawnSessions: false,
|
||||||
|
subsessions: false,
|
||||||
|
agentCommand: false,
|
||||||
|
agentDir: false,
|
||||||
|
agentSessionDir: false,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile";
|
||||||
|
import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||||
|
|
||||||
|
export type AgentProfileActivationState = "active" | "restart-required" | "unavailable";
|
||||||
|
|
||||||
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||||
return { spawnSessions: enabled };
|
return { spawnSessions: enabled };
|
||||||
@@ -8,15 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
|||||||
return { subsessions: enabled };
|
return { subsessions: enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentProfileActivationState(
|
||||||
|
config: PiWebConfigResponse | undefined,
|
||||||
|
activeProfile: ActiveAgentProfileDescriptor | undefined,
|
||||||
|
): AgentProfileActivationState {
|
||||||
|
const desiredProfile = config?.effectiveConfig.agent;
|
||||||
|
if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable";
|
||||||
|
const desiredSessionDirEnvKeys = [
|
||||||
|
"PI_WEB_AGENT_SESSION_DIR",
|
||||||
|
...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []),
|
||||||
|
];
|
||||||
|
return desiredProfile.command === activeProfile.command
|
||||||
|
&& desiredProfile.dir === activeProfile.dir
|
||||||
|
&& sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys)
|
||||||
|
? "active"
|
||||||
|
: "restart-required";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean {
|
||||||
|
if (envOverrides?.agentDirSource === "pi-web") return true;
|
||||||
|
if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi");
|
||||||
|
// Older remote responses do not identify the source. Keep their override
|
||||||
|
// read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional.
|
||||||
|
return envOverrides?.agentDir === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||||
|
const envOverrides: PiWebConfigEnvOverrides = {
|
||||||
|
...base.envOverrides,
|
||||||
|
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
||||||
|
subsessions: selectedMachine.envOverrides.subsessions,
|
||||||
|
agentCommand: selectedMachine.envOverrides.agentCommand,
|
||||||
|
agentDir: selectedMachine.envOverrides.agentDir,
|
||||||
|
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
|
||||||
|
};
|
||||||
|
if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource;
|
||||||
|
else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
config: { ...base.config, ...selectedMachine.config },
|
config: { ...base.config, ...selectedMachine.config },
|
||||||
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
|
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
|
||||||
envOverrides: {
|
envOverrides,
|
||||||
...base.envOverrides,
|
|
||||||
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
|
||||||
subsessions: selectedMachine.envOverrides.subsessions,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||||
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe("MachineController", () => {
|
|||||||
expect(projects.loadProjects).toHaveBeenCalledOnce();
|
expect(projects.loadProjects).toHaveBeenCalledOnce();
|
||||||
expect(updateUrl).toHaveBeenCalledOnce();
|
expect(updateUrl).toHaveBeenCalledOnce();
|
||||||
expect(health).toHaveBeenCalledWith(addedMachine.id);
|
expect(health).toHaveBeenCalledWith(addedMachine.id);
|
||||||
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
|
expect(runtime).toHaveBeenCalledWith(addedMachine.id, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the current machine state when adding a machine fails", async () => {
|
it("preserves the current machine state when adding a machine fails", async () => {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class MachineController {
|
|||||||
|
|
||||||
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const runtime = await api.runtime(machineId);
|
const runtime = await api.runtime(machineId, true);
|
||||||
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
|
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
|
|||||||
+116
-1
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||||
|
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let configPath: string;
|
let configPath: string;
|
||||||
@@ -63,6 +63,121 @@ describe("PI WEB config persistence", () => {
|
|||||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists and reads custom agent runtime settings", () => {
|
||||||
|
savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions());
|
||||||
|
|
||||||
|
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the Pi agent directory only for canonical Pi companion names", () => {
|
||||||
|
for (const command of ["pi", "pi.cmd"]) {
|
||||||
|
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({
|
||||||
|
command,
|
||||||
|
dir: join(tempDir, ".home", ".pi", "agent"),
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires explicit state for alternate names and absolute Pi launchers", () => {
|
||||||
|
const absolutePiCommand = join(tempDir, "bin", "pi");
|
||||||
|
for (const command of ["acme-agent", absolutePiCommand]) {
|
||||||
|
expect(() => effectiveAgentConfig({}, { agent: { command } })).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
|
||||||
|
expect(() => savePiWebConfig({ agent: { command } }, testOptions())).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts safe bare executable names and host-absolute executable paths", () => {
|
||||||
|
const absoluteCommand = join(tempDir, "bin", "acme-agent");
|
||||||
|
const agentDir = join(tempDir, "state", "acme");
|
||||||
|
|
||||||
|
expect(effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: agentDir } })).toMatchObject({ command: "acme-agent", dir: agentDir });
|
||||||
|
expect(effectiveAgentConfig({}, { agent: { command: absoluteCommand, dir: agentDir } })).toMatchObject({ command: absoluteCommand, dir: agentDir });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["./acme-agent", "bin/acme-agent", "../acme-agent", "node acme-agent.js", "acme-agent;other", "-acme-agent"])("rejects unsafe or workspace-relative agent command %j", (command) => {
|
||||||
|
expect(() => savePiWebConfig({ agent: { command, dir: join(tempDir, "agent") } }, testOptions())).toThrow("safe bare executable name or host-absolute executable path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")("rejects foreign-platform absolute agent command and state paths", () => {
|
||||||
|
expect(() => effectiveAgentConfig({}, { agent: { command: "C:\\tools\\acme-agent.exe", dir: join(tempDir, "agent") } })).toThrow("safe bare executable name or host-absolute executable path");
|
||||||
|
expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: "C:\\profiles\\acme" } })).toThrow("agent.dir must be a host-absolute path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects home expansion that would create a workspace-relative agent directory", () => {
|
||||||
|
expect(() => effectiveAgentConfig({ HOME: "relative-home" })).toThrow("agent.dir must be a host-absolute path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves explicit alternate agent command and state directory settings", () => {
|
||||||
|
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: join(tempDir, ".home", "agent-profiles", "acme"),
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty agent environment overrides", () => {
|
||||||
|
const env = {
|
||||||
|
HOME: join(tempDir, ".home"),
|
||||||
|
PI_WEB_AGENT_COMMAND: "",
|
||||||
|
PI_WEB_AGENT_DIR: "",
|
||||||
|
PI_WEB_AGENT_SESSION_DIR: "",
|
||||||
|
PI_CODING_AGENT_DIR: "",
|
||||||
|
PI_CODING_AGENT_SESSION_DIR: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: join(tempDir, ".home", "agent-profiles", "acme"),
|
||||||
|
});
|
||||||
|
expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false);
|
||||||
|
expect(hasAgentSessionDirEnvOverride(env, "acme-agent")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit PI WEB agent directory env precedence", () => {
|
||||||
|
const env = {
|
||||||
|
PI_WEB_AGENT_COMMAND: "acme-agent",
|
||||||
|
PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"),
|
||||||
|
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
|
||||||
|
};
|
||||||
|
expect(effectiveAgentConfig(env, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: join(tempDir, "web-env-agent"),
|
||||||
|
});
|
||||||
|
expect(agentDirEnvSource(env)).toBe("pi-web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => {
|
||||||
|
const legacyDir = join(tempDir, "pi-env-agent");
|
||||||
|
const alternateDir = join(tempDir, "alternate-agent");
|
||||||
|
const env = { PI_CODING_AGENT_DIR: legacyDir };
|
||||||
|
expect(effectiveAgentConfig(env, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir });
|
||||||
|
expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: alternateDir } })).toMatchObject({ command: "acme-agent", dir: alternateDir });
|
||||||
|
expect(agentDirEnvSource(env)).toBe("pi-compatibility");
|
||||||
|
expect(hasAgentDirEnvOverride(env, "pi")).toBe(true);
|
||||||
|
expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false);
|
||||||
|
|
||||||
|
for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) {
|
||||||
|
expect(() => effectiveAgentConfig(env, { agent: { command } }))
|
||||||
|
.toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only explicit session directory env keys", () => {
|
||||||
|
expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
|
||||||
|
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
|
||||||
|
expect(agentSessionDirEnvKeys(join(tempDir, "bin", "pi"))).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown nested agent keys instead of erasing them", async () => {
|
||||||
|
const original = { agent: { command: "acme-agent", dir: join(tempDir, "agent"), futureSetting: true } };
|
||||||
|
await writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
expect(() => loadPiWebConfig(testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
|
||||||
|
expect(() => savePiWebConfig({ port: 9000 }, testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
|
||||||
|
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes the default upload folder in the effective config", () => {
|
it("exposes the default upload folder in the effective config", () => {
|
||||||
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
||||||
});
|
});
|
||||||
|
|||||||
+179
-5
@@ -1,9 +1,12 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
||||||
import type { PiWebConfigValues } from "./shared/apiTypes.js";
|
import type { PiWebAgentDirEnvSource, PiWebConfigValues } from "./shared/apiTypes.js";
|
||||||
|
import { isPiCompanionCommand, usesPiCodingAgentStateCompatibility } from "./shared/activeAgentProfile.js";
|
||||||
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
|
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
|
||||||
|
|
||||||
|
export { isPiCompanionCommand };
|
||||||
|
|
||||||
export type PiWebConfig = PiWebConfigValues;
|
export type PiWebConfig = PiWebConfigValues;
|
||||||
|
|
||||||
export interface LoadedPiWebConfig {
|
export interface LoadedPiWebConfig {
|
||||||
@@ -12,6 +15,17 @@ export interface LoadedPiWebConfig {
|
|||||||
config: PiWebConfig;
|
config: PiWebConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> {
|
||||||
|
uploads: NonNullable<PiWebConfig["uploads"]>;
|
||||||
|
spawnSessions: boolean;
|
||||||
|
subsessions: boolean;
|
||||||
|
agent: Required<NonNullable<PiWebConfig["agent"]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedEffectivePiWebConfig extends Omit<LoadedPiWebConfig, "config"> {
|
||||||
|
config: EffectivePiWebConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadOptions {
|
export interface LoadOptions {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -35,6 +49,51 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|||||||
|
|
||||||
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
|
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_COMMAND = "pi";
|
||||||
|
export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND";
|
||||||
|
export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR";
|
||||||
|
export const PI_WEB_AGENT_SESSION_DIR_ENV = "PI_WEB_AGENT_SESSION_DIR";
|
||||||
|
export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
||||||
|
export const PI_CODING_AGENT_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||||
|
|
||||||
|
export interface EffectivePiWebAgentConfig {
|
||||||
|
command: string;
|
||||||
|
dir: string;
|
||||||
|
sessionDirEnvKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick<PiWebConfig, "agent"> = {}): EffectivePiWebAgentConfig {
|
||||||
|
const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current");
|
||||||
|
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesPiCodingAgentStateCompatibility(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"),
|
||||||
|
sessionDirEnvKeys: agentSessionDirEnvKeys(command),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] {
|
||||||
|
return uniqueStrings([
|
||||||
|
PI_WEB_AGENT_SESSION_DIR_ENV,
|
||||||
|
...(usesPiCodingAgentStateCompatibility(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentDirEnvSource(env: NodeJS.ProcessEnv): PiWebAgentDirEnvSource | undefined {
|
||||||
|
if (isEnvSet(env[PI_WEB_AGENT_DIR_ENV])) return "pi-web";
|
||||||
|
if (isEnvSet(env[PI_CODING_AGENT_DIR_ENV])) return "pi-compatibility";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
|
||||||
|
const source = agentDirEnvSource(env);
|
||||||
|
return source === "pi-web" || (source === "pi-compatibility" && usesPiCodingAgentStateCompatibility(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
|
||||||
|
return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key]));
|
||||||
|
}
|
||||||
|
|
||||||
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
|
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
|
||||||
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
|
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
|
||||||
}
|
}
|
||||||
@@ -72,14 +131,17 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
|
|||||||
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
|
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
|
export function effectivePiWebConfig(options: LoadOptions = {}): LoadedEffectivePiWebConfig {
|
||||||
const loaded = loadPiWebConfig(options);
|
return resolveEffectivePiWebConfig(loadPiWebConfig(options), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedEffectivePiWebConfig {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const host = env["PI_WEB_HOST"];
|
const host = env["PI_WEB_HOST"];
|
||||||
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
||||||
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
|
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
|
||||||
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
||||||
|
const agent = effectiveAgentConfig(env, loaded.config);
|
||||||
return {
|
return {
|
||||||
...loaded,
|
...loaded,
|
||||||
config: {
|
config: {
|
||||||
@@ -94,6 +156,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
|||||||
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
||||||
// Beta capability, resolved off by default.
|
// Beta capability, resolved off by default.
|
||||||
subsessions: subsessionsEnabled(env, loaded.config),
|
subsessions: subsessionsEnabled(env, loaded.config),
|
||||||
|
agent: { command: agent.command, dir: agent.dir },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -102,7 +165,9 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
|||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
|
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
|
||||||
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
|
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
|
||||||
|
effectiveAgentConfig(env, normalized);
|
||||||
const existing = readExistingConfigObject(path);
|
const existing = readExistingConfigObject(path);
|
||||||
|
if (existing["agent"] !== undefined) parseAgentConfig(existing["agent"], path);
|
||||||
delete existing["host"];
|
delete existing["host"];
|
||||||
delete existing["port"];
|
delete existing["port"];
|
||||||
delete existing["allowedHosts"];
|
delete existing["allowedHosts"];
|
||||||
@@ -113,6 +178,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
|||||||
delete existing["maxUploadBytes"];
|
delete existing["maxUploadBytes"];
|
||||||
delete existing["spawnSessions"];
|
delete existing["spawnSessions"];
|
||||||
delete existing["subsessions"];
|
delete existing["subsessions"];
|
||||||
|
delete existing["agent"];
|
||||||
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
||||||
@@ -138,6 +204,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
|||||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||||
|
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +220,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
|||||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||||
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
||||||
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
||||||
|
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +271,74 @@ function parseString(value: unknown, key: string, path: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AGENT_CONFIG_KEYS = new Set(["command", "dir"]);
|
||||||
|
const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u;
|
||||||
|
|
||||||
|
export type AgentPathHost = "current" | "portable";
|
||||||
|
|
||||||
|
export function parseAgentConfig(value: unknown, path: string, pathHost: AgentPathHost = "current"): NonNullable<PiWebConfig["agent"]> {
|
||||||
|
if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`);
|
||||||
|
const unknownKey = Object.keys(value).find((key) => !AGENT_CONFIG_KEYS.has(key));
|
||||||
|
if (unknownKey !== undefined) throw new Error(`PI WEB config agent contains unknown key ${JSON.stringify(unknownKey)}: ${path}`);
|
||||||
|
const command = value["command"];
|
||||||
|
const dir = value["dir"];
|
||||||
|
return {
|
||||||
|
...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path, pathHost) } : {}),
|
||||||
|
...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path, pathHost) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentCommand(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
|
||||||
|
const command = parseString(value, key, path).trim();
|
||||||
|
if (!isSafeAgentCommand(command, pathHost)) {
|
||||||
|
const absoluteLabel = pathHost === "current" ? "host-absolute" : "absolute";
|
||||||
|
throw new Error(`PI WEB config ${key} must be a safe bare executable name or ${absoluteLabel} executable path: ${path}`);
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentDir(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
|
||||||
|
const dir = parseString(value, key, path).trim();
|
||||||
|
const isAbsoluteDir = pathHost === "current" ? isHostAbsoluteAgentDir(dir) : isPortableAbsoluteAgentPath(dir);
|
||||||
|
if (!isAbsoluteDir && !isHomePath(dir, pathHost)) {
|
||||||
|
const absoluteLabel = pathHost === "current" ? "a host-absolute" : "an absolute";
|
||||||
|
throw new Error(`PI WEB config ${key} must be ${absoluteLabel} path or start with ~: ${path}`);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, key: string, path: string): string {
|
||||||
|
const parsed = parseAgentDir(value, key, path, "current");
|
||||||
|
const expanded = expandHomePath(parsed, env);
|
||||||
|
if (!isHostAbsoluteAgentDir(expanded)) {
|
||||||
|
throw new Error(`PI WEB config ${key} must resolve to a host-absolute path: ${path}`);
|
||||||
|
}
|
||||||
|
return normalize(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeAgentCommandForHost(value: string): boolean {
|
||||||
|
return isSafeAgentCommand(value, "current");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeAgentCommand(value: string, pathHost: AgentPathHost): boolean {
|
||||||
|
if (value === "" || value !== value.trim() || value.includes("\0") || /[\s;&|`$<>]/u.test(value)) return false;
|
||||||
|
if (SAFE_BARE_AGENT_COMMAND_PATTERN.test(value)) return true;
|
||||||
|
if (pathHost === "current") return isAbsolute(value) && basename(value) !== "";
|
||||||
|
return isAbsoluteLike(value) && value.split(/[\\/]/u).at(-1) !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHostAbsoluteAgentDir(value: string): boolean {
|
||||||
|
return isSafeAgentDirPath(value) && isAbsolute(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortableAbsoluteAgentPath(value: string): boolean {
|
||||||
|
return isSafeAgentDirPath(value) && isAbsoluteLike(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeAgentDirPath(value: string): boolean {
|
||||||
|
return value !== "" && value === value.trim() && !hasControlCharacter(value);
|
||||||
|
}
|
||||||
|
|
||||||
function parsePort(value: unknown, key: string, path = "environment"): number {
|
function parsePort(value: unknown, key: string, path = "environment"): number {
|
||||||
const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
|
const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`);
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`);
|
||||||
@@ -252,6 +388,44 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string)
|
|||||||
return parts.join("/");
|
return parts.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isHomePath(value: string, pathHost: AgentPathHost): boolean {
|
||||||
|
return value === "~" || value.startsWith("~/") || ((pathHost === "portable" || process.platform === "win32") && value.startsWith("~\\"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
|
||||||
|
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
|
||||||
|
if (value === "~") return home;
|
||||||
|
if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) return join(home, value.slice(2));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
|
||||||
|
if (usesPiCodingAgentStateCompatibility(command)) return expandHomePath("~/.pi/agent", env);
|
||||||
|
throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||||
|
const value = env[key];
|
||||||
|
return value !== undefined && value !== "" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnvSet(value: string | undefined): boolean {
|
||||||
|
return value !== undefined && value !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: readonly string[]): string[] {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
if (code < 32 || code === 127) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isAbsoluteLike(value: string): boolean {
|
function isAbsoluteLike(value: string): boolean {
|
||||||
const withForwardSlashes = value.replace(/\\/g, "/");
|
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||||
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
|
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
|
||||||
|
import type { SessionDaemonRequestClient } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
import {
|
||||||
|
ActiveAgentProfileAccessError,
|
||||||
|
requireActiveAgentProfile,
|
||||||
|
SessionDaemonActiveAgentProfileProvider,
|
||||||
|
} from "./activeAgentProfileProvider.js";
|
||||||
|
|
||||||
|
const firstProfile = activeProfile("a", "first-agent", "/state/first");
|
||||||
|
const secondProfile = activeProfile("b", "second-agent", "/state/second");
|
||||||
|
|
||||||
|
describe("SessionDaemonActiveAgentProfileProvider", () => {
|
||||||
|
it("queries sessiond on every read and observes a new daemon profile epoch", async () => {
|
||||||
|
const request = vi.fn<SessionDaemonRequestClient["request"]>()
|
||||||
|
.mockResolvedValueOnce(runtimeResponse(firstProfile))
|
||||||
|
.mockResolvedValueOnce(runtimeResponse(secondProfile));
|
||||||
|
const provider = new SessionDaemonActiveAgentProfileProvider({ request });
|
||||||
|
|
||||||
|
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: firstProfile });
|
||||||
|
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: secondProfile });
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
expect(request).toHaveBeenNthCalledWith(1, "GET", "/runtime");
|
||||||
|
expect(request).toHaveBeenNthCalledWith(2, "GET", "/runtime");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves invalid protocol and daemon unavailability as distinct results", async () => {
|
||||||
|
const invalidRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not-json",
|
||||||
|
});
|
||||||
|
const unavailableRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||||
|
|
||||||
|
await expect(new SessionDaemonActiveAgentProfileProvider({ request: invalidRequest }).getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "invalid",
|
||||||
|
error: "session daemon runtime response was not valid JSON",
|
||||||
|
});
|
||||||
|
await expect(new SessionDaemonActiveAgentProfileProvider({ request: unavailableRequest }).getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "unavailable",
|
||||||
|
error: "connect ECONNREFUSED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireActiveAgentProfile", () => {
|
||||||
|
it.each(["invalid", "unavailable"] as const)("fails closed for an %s active profile", async (status) => {
|
||||||
|
const provider = {
|
||||||
|
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` } as const),
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = await requireActiveAgentProfile(provider).catch((caught: unknown) => caught);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ActiveAgentProfileAccessError);
|
||||||
|
expect(error).toMatchObject({ profileStatus: status, message: `Active agent profile is ${status}: ${status} profile` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${revisionCharacter.repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeResponse(profile: ActiveAgentProfileDescriptor) {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
activeAgentProfile: profile,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
|
||||||
|
import {
|
||||||
|
getSessionDaemonActiveAgentProfile,
|
||||||
|
type SessionDaemonAgentProfileResult,
|
||||||
|
type SessionDaemonRequestClient,
|
||||||
|
} from "../sessiond/sessionDaemonClient.js";
|
||||||
|
|
||||||
|
export interface ActiveAgentProfileProvider {
|
||||||
|
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the daemon-owned profile on every call so a new sessiond epoch is observed. */
|
||||||
|
export class SessionDaemonActiveAgentProfileProvider implements ActiveAgentProfileProvider {
|
||||||
|
constructor(private readonly daemon: SessionDaemonRequestClient) {}
|
||||||
|
|
||||||
|
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
|
||||||
|
return getSessionDaemonActiveAgentProfile(this.daemon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActiveAgentProfileAccessError extends Error {
|
||||||
|
readonly profileStatus: "unavailable" | "invalid";
|
||||||
|
|
||||||
|
constructor(result: Exclude<SessionDaemonAgentProfileResult, { status: "available" }>) {
|
||||||
|
const label = result.status === "unavailable" ? "unavailable" : "invalid";
|
||||||
|
super(`Active agent profile is ${label}: ${result.error}`);
|
||||||
|
this.name = "ActiveAgentProfileAccessError";
|
||||||
|
this.profileStatus = result.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireActiveAgentProfile(provider: ActiveAgentProfileProvider): Promise<ActiveAgentProfileDescriptor> {
|
||||||
|
const result = await provider.getActiveAgentProfile();
|
||||||
|
if (result.status !== "available") throw new ActiveAgentProfileAccessError(result);
|
||||||
|
return result.profile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebPluginInfo } from "../shared/apiTypes.js";
|
||||||
|
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
import type { ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
|
||||||
|
import { buildApp } from "./app.js";
|
||||||
|
import type { PiWebConfigService } from "./configRoutes.js";
|
||||||
|
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "pi-web-active-profile-app-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildApp active profile composition", () => {
|
||||||
|
it("routes package and package-backed plugin reads through the same refreshable provider", async () => {
|
||||||
|
const firstAgentDir = join(tempDir, "first-agent");
|
||||||
|
const secondAgentDir = join(tempDir, "second-agent");
|
||||||
|
const firstPackageDir = join(tempDir, "first-package");
|
||||||
|
const secondPackageDir = join(tempDir, "second-package");
|
||||||
|
await Promise.all([
|
||||||
|
writePackagePlugin(firstPackageDir, "profile-first"),
|
||||||
|
writePackagePlugin(secondPackageDir, "profile-second"),
|
||||||
|
writePiPackageSettings(firstAgentDir, [firstPackageDir]),
|
||||||
|
writePiPackageSettings(secondAgentDir, [secondPackageDir]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result: SessionDaemonAgentProfileResult = { status: "available", profile: activeProfile("a", "first-agent", firstAgentDir) };
|
||||||
|
const getActiveAgentProfile = vi.fn(() => Promise.resolve(result));
|
||||||
|
const app = await buildApp({
|
||||||
|
agentProfileProvider: { getActiveAgentProfile },
|
||||||
|
config: emptyConfigService(),
|
||||||
|
clientDist: false,
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||||
|
const firstPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||||
|
expect(firstPackages.statusCode).toBe(200);
|
||||||
|
expect(packageSources(firstPackages.json())).toContain(firstPackageDir);
|
||||||
|
expect(pluginIds(firstPlugins.json())).toContain("profile-first");
|
||||||
|
expect(pluginIds(firstPlugins.json())).not.toContain("profile-second");
|
||||||
|
|
||||||
|
result = { status: "available", profile: activeProfile("b", "second-agent", secondAgentDir) };
|
||||||
|
|
||||||
|
const secondPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||||
|
const secondPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||||
|
expect(secondPackages.statusCode).toBe(200);
|
||||||
|
expect(packageSources(secondPackages.json())).toContain(secondPackageDir);
|
||||||
|
expect(packageSources(secondPackages.json())).not.toContain(firstPackageDir);
|
||||||
|
expect(pluginIds(secondPlugins.json())).toContain("profile-second");
|
||||||
|
expect(pluginIds(secondPlugins.json())).not.toContain("profile-first");
|
||||||
|
expect(getActiveAgentProfile).toHaveBeenCalledTimes(4);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["unavailable", "invalid"] as const)("returns 503 instead of falling back when the active profile is %s", async (status) => {
|
||||||
|
const provider: ActiveAgentProfileProvider = {
|
||||||
|
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} daemon profile` }),
|
||||||
|
};
|
||||||
|
const app = await buildApp({
|
||||||
|
agentProfileProvider: provider,
|
||||||
|
config: emptyConfigService(),
|
||||||
|
clientDist: false,
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packages = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||||
|
const plugins = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||||
|
const manifest = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||||
|
|
||||||
|
expect(packages.statusCode).toBe(503);
|
||||||
|
expect(packages.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
|
||||||
|
expect(plugins.statusCode).toBe(503);
|
||||||
|
expect(plugins.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
|
||||||
|
expect(manifest.statusCode).toBe(503);
|
||||||
|
expect(manifest.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${revisionCharacter.repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePackagePlugin(root: string, pluginId: string): Promise<void> {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(join(root, "package.json"), `${JSON.stringify({
|
||||||
|
name: `@test/${pluginId}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
piWeb: { plugins: [{ id: pluginId, module: "pi-web-plugin.js" }] },
|
||||||
|
}, null, 2)}\n`, "utf8");
|
||||||
|
await writeFile(join(root, "pi-web-plugin.js"), "export default {};\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyConfigService(): PiWebConfigService {
|
||||||
|
const response: PiWebConfigResponse = {
|
||||||
|
path: join(tempDir, "config.json"),
|
||||||
|
exists: false,
|
||||||
|
config: {},
|
||||||
|
effectiveConfig: {},
|
||||||
|
envOverrides: {
|
||||||
|
host: false,
|
||||||
|
port: false,
|
||||||
|
allowedHosts: false,
|
||||||
|
spawnSessions: false,
|
||||||
|
subsessions: false,
|
||||||
|
agentCommand: false,
|
||||||
|
agentDir: false,
|
||||||
|
agentSessionDir: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
read: () => Promise.resolve(response),
|
||||||
|
write: () => Promise.resolve(response),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageSources(value: unknown): string[] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value["packages"])) return [];
|
||||||
|
return value["packages"].flatMap((entry) => isRecord(entry) && typeof entry["source"] === "string" ? [entry["source"]] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginIds(value: unknown): PiWebPluginInfo["id"][] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value["plugins"])) return [];
|
||||||
|
return value["plugins"].flatMap((entry) => isRecord(entry) && typeof entry["id"] === "string" ? [entry["id"]] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ActiveAgentProfileDescriptor, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||||
|
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
|
||||||
|
|
||||||
|
registerAppTestHooks();
|
||||||
|
|
||||||
|
describe("buildApp active agent profile", () => {
|
||||||
|
it.each(["/api/config", "/api/machines/local/config"])("keeps desired writes separate from the active profile and observes a new daemon epoch through %s", async (configRoute) => {
|
||||||
|
const originalEnv = captureEnv([
|
||||||
|
"PI_WEB_SKIP_VERSION_CHECK",
|
||||||
|
"PI_WEB_DOCKER_RUNTIME",
|
||||||
|
"PI_WEB_AGENT_COMMAND",
|
||||||
|
"PI_WEB_AGENT_DIR",
|
||||||
|
"PI_CODING_AGENT_DIR",
|
||||||
|
]);
|
||||||
|
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||||
|
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
|
||||||
|
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_COMMAND");
|
||||||
|
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_DIR");
|
||||||
|
Reflect.deleteProperty(process.env, "PI_CODING_AGENT_DIR");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initialAgentDir = join(appTestContext.tempDir, "initial-agent");
|
||||||
|
const updatedAgentDir = join(appTestContext.tempDir, "updated-agent");
|
||||||
|
appTestContext.piWebConfig = { agent: { command: "desired-agent", dir: initialAgentDir } };
|
||||||
|
appTestContext.agentProfileResult = { status: "available", profile: activeProfile("a", "active-agent", initialAgentDir) };
|
||||||
|
await mkdir(initialAgentDir, { recursive: true });
|
||||||
|
await installConfiguredPiWebPackage(updatedAgentDir);
|
||||||
|
process.env["PI_WEB_AGENT_DIR"] = updatedAgentDir;
|
||||||
|
|
||||||
|
const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
|
||||||
|
expect(initialStatus.statusCode).toBe(200);
|
||||||
|
expect(initialStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
|
||||||
|
|
||||||
|
const updateResponse = await appTestContext.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: configRoute,
|
||||||
|
payload: { config: { agent: { command: "next-agent", dir: updatedAgentDir } } },
|
||||||
|
});
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const desiredWriteStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
|
||||||
|
const desiredWriteVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
|
||||||
|
expect(desiredWriteStatus.statusCode).toBe(200);
|
||||||
|
expect(desiredWriteStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
|
||||||
|
expect(desiredWriteVersion.json<PiWebVersionResponse>().components.web.installation?.kind).not.toBe("pi-package");
|
||||||
|
|
||||||
|
appTestContext.agentProfileResult = { status: "available", profile: activeProfile("b", "next-agent", updatedAgentDir) };
|
||||||
|
const restartedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
|
||||||
|
const restartedVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
|
||||||
|
|
||||||
|
expect(restartedStatus.statusCode).toBe(200);
|
||||||
|
expect(restartedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({
|
||||||
|
kind: "pi-package",
|
||||||
|
source: process.cwd(),
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
expect(restartedVersion.json<PiWebVersionResponse>().components.web.installation).toMatchObject({
|
||||||
|
kind: "pi-package",
|
||||||
|
source: process.cwd(),
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreEnv(originalEnv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${revisionCharacter.repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEnv(keys: readonly string[]): Map<string, string | undefined> {
|
||||||
|
return new Map(keys.map((key) => [key, process.env[key]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(values: ReadonlyMap<string, string | undefined>): void {
|
||||||
|
for (const [key, value] of values) {
|
||||||
|
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,18 +61,39 @@ describe("buildApp machine routes", () => {
|
|||||||
packageName: "@jmfederico/pi-web",
|
packageName: "@jmfederico/pi-web",
|
||||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
components: {
|
components: {
|
||||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
|
||||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
sessiond: {
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Remote Sessiond",
|
||||||
|
runtimeVersion: "1.0.0",
|
||||||
|
available: true,
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||||
|
activeAgentProfile: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "remote-agent",
|
||||||
|
dir: "/srv/remote-agent",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
},
|
},
|
||||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
},
|
||||||
|
},
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||||
|
const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` });
|
||||||
|
|
||||||
expect(runtime.statusCode).toBe(200);
|
expect(runtime.statusCode).toBe(200);
|
||||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
expect(refreshedRuntime.statusCode).toBe(200);
|
||||||
|
expect(runtime.json()).toMatchObject({
|
||||||
|
machineId: remote.id,
|
||||||
|
ok: true,
|
||||||
|
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||||
|
components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||||
|
});
|
||||||
|
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,16 +122,18 @@ describe("buildApp machine routes", () => {
|
|||||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
const remote = addResponse.json<{ id: string }>();
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
let persistedConfig = fullPiWebConfig();
|
||||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
|
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
|
persistedConfig = configFromMachineConfigWriteBody(body);
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
|
||||||
});
|
});
|
||||||
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
const response = await appTestContext.app.inject({
|
const response = await appTestContext.app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: `/api/machines/${remote.id}/config`,
|
url: `/api/machines/${remote.id}/config`,
|
||||||
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
|
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true, agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedMerged: PiWebConfigValues = {
|
const expectedMerged: PiWebConfigValues = {
|
||||||
@@ -120,6 +143,7 @@ describe("buildApp machine routes", () => {
|
|||||||
uploads: { defaultFolder: "remote/uploads" },
|
uploads: { defaultFolder: "remote/uploads" },
|
||||||
maxUploadBytes: 4096,
|
maxUploadBytes: 4096,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
|
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
|
||||||
};
|
};
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||||
@@ -131,6 +155,103 @@ describe("buildApp machine routes", () => {
|
|||||||
maxUploadBytes: 4096,
|
maxUploadBytes: 4096,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a false-success agent profile write from an older remote machine", async () => {
|
||||||
|
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const legacyConfig = fullPiWebConfig();
|
||||||
|
delete legacyConfig.agent;
|
||||||
|
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: piWebConfigResponse(legacyConfig),
|
||||||
|
}));
|
||||||
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
|
const response = await appTestContext.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/machines/${remote.id}/config`,
|
||||||
|
payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
error: "Remote machine did not persist the requested agent profile",
|
||||||
|
machineId: remote.id,
|
||||||
|
});
|
||||||
|
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||||
|
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||||
|
config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => {
|
||||||
|
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const requestJson = vi.fn<MachineClient["requestJson"]>((method) => {
|
||||||
|
const config = fullPiWebConfig();
|
||||||
|
if (method === "PUT") delete config.agent;
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||||
|
});
|
||||||
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
|
const response = await appTestContext.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/machines/${remote.id}/config`,
|
||||||
|
payload: { config: { agent: {} } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" });
|
||||||
|
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||||
|
config: { ...fullPiWebConfig(), agent: {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-profile selected-machine saves compatible with older remote machines", async () => {
|
||||||
|
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const legacyConfig = fullPiWebConfig();
|
||||||
|
delete legacyConfig.agent;
|
||||||
|
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||||
|
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig;
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||||
|
});
|
||||||
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
|
const response = await appTestContext.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/machines/${remote.id}/config`,
|
||||||
|
payload: { config: { spawnSessions: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json<PiWebConfigResponse>().config.spawnSessions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves foreign-platform agent paths while the target verifies persistence", async () => {
|
||||||
|
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||||
|
const remote = addResponse.json<{ id: string }>();
|
||||||
|
const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" };
|
||||||
|
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||||
|
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig();
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
|
||||||
|
});
|
||||||
|
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
|
||||||
|
|
||||||
|
const response = await appTestContext.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/machines/${remote.id}/config`,
|
||||||
|
payload: { config: { agent: windowsAgent } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json<PiWebConfigResponse>().config.agent).toEqual(windowsAgent);
|
||||||
|
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
|
||||||
|
config: { ...fullPiWebConfig(), agent: windowsAgent },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ describe("PI WEB status routes", () => {
|
|||||||
it("forces a fresh status load when refresh is requested", async () => {
|
it("forces a fresh status load when refresh is requested", async () => {
|
||||||
const get = vi.fn(() => Promise.resolve(status("cached")));
|
const get = vi.fn(() => Promise.resolve(status("cached")));
|
||||||
const refresh = vi.fn(() => Promise.resolve(status("forced")));
|
const refresh = vi.fn(() => Promise.resolve(status("forced")));
|
||||||
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
|
const invalidate = vi.fn();
|
||||||
|
const app = await buildApp({ piWebStatusCache: { get, refresh, invalidate }, clientDist: false, logger: false });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
|
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { WorkspaceService } from "./workspaces/workspaceService.js";
|
|||||||
import type { PiPackageService } from "./piPackageService.js";
|
import type { PiPackageService } from "./piPackageService.js";
|
||||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
import type { ActiveAgentProfileDescriptor, PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||||
|
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
|
||||||
interface AppTestContext {
|
interface AppTestContext {
|
||||||
readonly app: FastifyInstance;
|
readonly app: FastifyInstance;
|
||||||
@@ -24,6 +25,7 @@ interface AppTestContext {
|
|||||||
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||||
readonly piPackageRequests: CapturedPiPackageRequest[];
|
readonly piPackageRequests: CapturedPiPackageRequest[];
|
||||||
piWebConfig: PiWebConfigValues;
|
piWebConfig: PiWebConfigValues;
|
||||||
|
agentProfileResult: SessionDaemonAgentProfileResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: FastifyInstance | undefined;
|
let app: FastifyInstance | undefined;
|
||||||
@@ -33,6 +35,7 @@ let remoteClient: MachineClient | undefined;
|
|||||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
|
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
|
||||||
let piPackageRequests: CapturedPiPackageRequest[] = [];
|
let piPackageRequests: CapturedPiPackageRequest[] = [];
|
||||||
let piWebConfig: PiWebConfigValues = {};
|
let piWebConfig: PiWebConfigValues = {};
|
||||||
|
let agentProfileResult: SessionDaemonAgentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
|
||||||
|
|
||||||
export const appTestContext: AppTestContext = {
|
export const appTestContext: AppTestContext = {
|
||||||
get app() {
|
get app() {
|
||||||
@@ -65,6 +68,12 @@ export const appTestContext: AppTestContext = {
|
|||||||
set piWebConfig(config) {
|
set piWebConfig(config) {
|
||||||
piWebConfig = config;
|
piWebConfig = config;
|
||||||
},
|
},
|
||||||
|
get agentProfileResult() {
|
||||||
|
return agentProfileResult;
|
||||||
|
},
|
||||||
|
set agentProfileResult(result) {
|
||||||
|
agentProfileResult = result;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerAppTestHooks(): void {
|
export function registerAppTestHooks(): void {
|
||||||
@@ -75,6 +84,7 @@ export function registerAppTestHooks(): void {
|
|||||||
sessionDaemonRequests = [];
|
sessionDaemonRequests = [];
|
||||||
piPackageRequests = [];
|
piPackageRequests = [];
|
||||||
piWebConfig = {};
|
piWebConfig = {};
|
||||||
|
agentProfileResult = { status: "available", profile: appTestAgentProfile(join(tempDir, "agent")) };
|
||||||
app = await buildApp({
|
app = await buildApp({
|
||||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||||
workspaces: new WorkspaceService(),
|
workspaces: new WorkspaceService(),
|
||||||
@@ -95,6 +105,7 @@ export function registerAppTestHooks(): void {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
sessionDaemon: fakeSessionDaemon(),
|
sessionDaemon: fakeSessionDaemon(),
|
||||||
|
agentProfileProvider: { getActiveAgentProfile: () => Promise.resolve(agentProfileResult) },
|
||||||
config: fakeConfigService(),
|
config: fakeConfigService(),
|
||||||
piPackages: fakePiPackageService(),
|
piPackages: fakePiPackageService(),
|
||||||
piWebPlugins: {
|
piWebPlugins: {
|
||||||
@@ -117,6 +128,7 @@ export function registerAppTestHooks(): void {
|
|||||||
sessionDaemonRequests = [];
|
sessionDaemonRequests = [];
|
||||||
piPackageRequests = [];
|
piPackageRequests = [];
|
||||||
piWebConfig = {};
|
piWebConfig = {};
|
||||||
|
agentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
|
||||||
|
|
||||||
if (appToClose !== undefined) await appToClose.close();
|
if (appToClose !== undefined) await appToClose.close();
|
||||||
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
|
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
|
||||||
@@ -152,6 +164,16 @@ function fakeConfigService() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appTestAgentProfile(dir: string): ActiveAgentProfileDescriptor {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "pi",
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function fullPiWebConfig(): PiWebConfigValues {
|
export function fullPiWebConfig(): PiWebConfigValues {
|
||||||
return {
|
return {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -164,6 +186,7 @@ export function fullPiWebConfig(): PiWebConfigValues {
|
|||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
spawnSessions: false,
|
spawnSessions: false,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +198,7 @@ export function selectedMachinePiWebConfig(): PiWebConfigValues {
|
|||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
spawnSessions: false,
|
spawnSessions: false,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo
|
|||||||
exists: false,
|
exists: false,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-11
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify";
|
||||||
import fastifyCompress from "@fastify/compress";
|
import fastifyCompress from "@fastify/compress";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import fastifyWebsocket from "@fastify/websocket";
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
@@ -21,10 +21,16 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
|||||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||||
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||||
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
|
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
|
||||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||||
|
import {
|
||||||
|
ActiveAgentProfileAccessError,
|
||||||
|
requireActiveAgentProfile,
|
||||||
|
SessionDaemonActiveAgentProfileProvider,
|
||||||
|
type ActiveAgentProfileProvider,
|
||||||
|
} from "./activeAgentProfileProvider.js";
|
||||||
import { MachineService } from "./machines/machineService.js";
|
import { MachineService } from "./machines/machineService.js";
|
||||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||||
@@ -36,6 +42,7 @@ export interface AppDependencies {
|
|||||||
workspaces?: WorkspaceService;
|
workspaces?: WorkspaceService;
|
||||||
machines?: MachineService;
|
machines?: MachineService;
|
||||||
sessionDaemon?: SessionProxyDaemon;
|
sessionDaemon?: SessionProxyDaemon;
|
||||||
|
agentProfileProvider?: ActiveAgentProfileProvider;
|
||||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||||
piPackages?: PiPackageService;
|
piPackages?: PiPackageService;
|
||||||
piWebStatusCache?: PiWebStatusCache;
|
piWebStatusCache?: PiWebStatusCache;
|
||||||
@@ -120,6 +127,30 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: Proje
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readEffectiveConfig(config: Pick<PiWebConfigService, "read">) {
|
||||||
|
return (await config.read()).effectiveConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick<PiWebStatusCache, "invalidate">): PiWebConfigService {
|
||||||
|
return {
|
||||||
|
read: () => config.read(),
|
||||||
|
write: async (nextConfig) => {
|
||||||
|
const response = await config.write(nextConfig);
|
||||||
|
statusCache.invalidate();
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withProfileDependency<T>(reply: FastifyReply, operation: () => Promise<T>): Promise<T | FastifyReply> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ActiveAgentProfileAccessError)) throw error;
|
||||||
|
return reply.code(503).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
||||||
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
|
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
|
||||||
// Vite proxies development API requests here, while production and machine-scoped
|
// Vite proxies development API requests here, while production and machine-scoped
|
||||||
@@ -133,39 +164,56 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
|
||||||
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
|
||||||
const configService = deps.config ?? createFilePiWebConfigService();
|
const configService = deps.config ?? createFilePiWebConfigService();
|
||||||
|
const readConfig = () => readEffectiveConfig(configService);
|
||||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||||
|
const agentProfileProvider = deps.agentProfileProvider ?? new SessionDaemonActiveAgentProfileProvider(sessionDaemon);
|
||||||
|
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({
|
||||||
|
configProvider: readConfig,
|
||||||
|
agentDirProvider: async () => (await requireActiveAgentProfile(agentProfileProvider)).dir,
|
||||||
|
});
|
||||||
|
const piPackages = deps.piPackages ?? createActiveProfilePiPackageService(agentProfileProvider);
|
||||||
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
|
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
|
||||||
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
|
async ({ force }) => {
|
||||||
|
const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
|
||||||
|
return getPiWebStatus(sessionDaemon, {
|
||||||
|
forceReleaseCheck: force,
|
||||||
|
...(activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
|
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
|
||||||
);
|
);
|
||||||
const machines = deps.machines ?? new MachineService(undefined, {
|
const machines = deps.machines ?? new MachineService(undefined, {
|
||||||
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
app.get("/pi-web-plugins/manifest.json", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.manifest()));
|
||||||
|
|
||||||
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
|
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
|
||||||
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
|
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
|
||||||
|
|
||||||
|
return withProfileDependency(reply, async () => {
|
||||||
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
|
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
|
||||||
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
|
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
|
||||||
return reply.type(asset.contentType).send(asset.content);
|
return reply.type(asset.contentType).send(asset.content);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
|
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
|
||||||
? piWebStatusCache.refresh({ force: true })
|
? piWebStatusCache.refresh({ force: true })
|
||||||
: piWebStatusCache.get());
|
: piWebStatusCache.get());
|
||||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
app.get("/api/pi-web/version", async () => {
|
||||||
|
const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
|
||||||
|
return getPiWebVersionStatus(sessionDaemon, activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {});
|
||||||
|
});
|
||||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
app.get("/api/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
|
||||||
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
|
app.get("/api/machines/local/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
|
||||||
registerPiPackageRoutes(app, piPackages);
|
registerPiPackageRoutes(app, piPackages);
|
||||||
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||||
registerConfigRoutes(app, configService);
|
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
|
||||||
registerLocalMachineConfigRoutes(app, configService);
|
registerConfigRoutes(app, invalidatingConfigService);
|
||||||
|
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
|
||||||
|
|
||||||
registerMachineRoutes(app, machines);
|
registerMachineRoutes(app, machines);
|
||||||
registerMachinePluginProxyRoutes(app, machines);
|
registerMachinePluginProxyRoutes(app, machines);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
import { parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
@@ -46,6 +46,7 @@ describe("config routes", () => {
|
|||||||
pathAccess: { allowedPaths: ["/tmp"] },
|
pathAccess: { allowedPaths: ["/tmp"] },
|
||||||
uploads: { defaultFolder: "uploads\\manual" },
|
uploads: { defaultFolder: "uploads\\manual" },
|
||||||
maxUploadBytes: 1234,
|
maxUploadBytes: 1234,
|
||||||
|
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
|
||||||
};
|
};
|
||||||
const expectedConfig: PiWebConfigValues = {
|
const expectedConfig: PiWebConfigValues = {
|
||||||
...requestedConfig,
|
...requestedConfig,
|
||||||
@@ -111,6 +112,21 @@ describe("config routes", () => {
|
|||||||
expect(service.write).not.toHaveBeenCalled();
|
expect(service.write).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ agent: { command: "./agent", dir: "/srv/agent" }, error: "safe bare executable name or host-absolute executable path" },
|
||||||
|
{ agent: { command: "agent", dir: "/srv/agent", futureSetting: true }, error: 'agent contains unknown key "futureSetting"' },
|
||||||
|
])("rejects unsafe agent profile payloads before writing", async ({ agent, error }) => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/config",
|
||||||
|
payload: { config: { agent } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json<{ error: string }>().error).toContain(error);
|
||||||
|
expect(service.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("filters local machine config reads to selected-machine-safe keys", async () => {
|
it("filters local machine config reads to selected-machine-safe keys", async () => {
|
||||||
savedConfig = fullConfig();
|
savedConfig = fullConfig();
|
||||||
|
|
||||||
@@ -130,6 +146,7 @@ describe("config routes", () => {
|
|||||||
plugins: { info: { enabled: false } },
|
plugins: { info: { enabled: false } },
|
||||||
uploads: { defaultFolder: "uploads\\manual" },
|
uploads: { defaultFolder: "uploads\\manual" },
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
|
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -143,6 +160,7 @@ describe("config routes", () => {
|
|||||||
plugins: { info: { enabled: false } },
|
plugins: { info: { enabled: false } },
|
||||||
uploads: { defaultFolder: "uploads/manual" },
|
uploads: { defaultFolder: "uploads/manual" },
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
|
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
|
||||||
};
|
};
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(savedConfig).toEqual(expectedConfig);
|
expect(savedConfig).toEqual(expectedConfig);
|
||||||
@@ -154,9 +172,53 @@ describe("config routes", () => {
|
|||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps foreign-platform agent paths portable at federation transport boundaries", () => {
|
||||||
|
const agent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\pi" };
|
||||||
|
const response = {
|
||||||
|
...responseFor({ agent }, true),
|
||||||
|
effectiveConfig: { agent },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsePiWebConfigResponseBody(response).config.agent).toEqual(agent);
|
||||||
|
expect(parseSelectedMachineConfigRequest({ agent }, "portable").agent).toEqual(agent);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(() => parseSelectedMachineConfigRequest({ agent })).toThrow("host-absolute executable path");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults missing agent override fields from older config responses", () => {
|
||||||
|
const parsed = parsePiWebConfigResponseBody({
|
||||||
|
path: "/tmp/pi-web/config.json",
|
||||||
|
exists: true,
|
||||||
|
config: {},
|
||||||
|
effectiveConfig: {},
|
||||||
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the agent directory environment source across federation responses", () => {
|
||||||
|
const parsed = parsePiWebConfigResponseBody({
|
||||||
|
...responseFor({}, false),
|
||||||
|
envOverrides: {
|
||||||
|
...responseFor({}, false).envOverrides,
|
||||||
|
agentDir: true,
|
||||||
|
agentDirSource: "pi-compatibility",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" });
|
||||||
|
expect(() => parsePiWebConfigResponseBody({
|
||||||
|
...responseFor({}, false),
|
||||||
|
envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" },
|
||||||
|
})).toThrow("valid agent directory source");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||||
savedConfig = fullConfig();
|
savedConfig = fullConfig();
|
||||||
|
|
||||||
@@ -197,6 +259,7 @@ function fullConfig(): PiWebConfigValues {
|
|||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
spawnSessions: false,
|
spawnSessions: false,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +271,7 @@ function selectedMachineConfig(): PiWebConfigValues {
|
|||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
spawnSessions: false,
|
spawnSessions: false,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
|
|||||||
exists,
|
exists,
|
||||||
config,
|
config,
|
||||||
effectiveConfig: config,
|
effectiveConfig: config,
|
||||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-10
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||||
|
|
||||||
export interface PiWebConfigService {
|
export interface PiWebConfigService {
|
||||||
@@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [
|
|||||||
"maxUploadBytes",
|
"maxUploadBytes",
|
||||||
"spawnSessions",
|
"spawnSessions",
|
||||||
"subsessions",
|
"subsessions",
|
||||||
|
"agent",
|
||||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||||
|
|
||||||
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
|
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
|
||||||
@@ -31,14 +32,14 @@ export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebCo
|
|||||||
|
|
||||||
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
|
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
|
||||||
const loaded = loadPiWebConfig(options);
|
const loaded = loadPiWebConfig(options);
|
||||||
const effective = effectivePiWebConfig(options);
|
const effective = resolveEffectivePiWebConfig(loaded, options);
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
return {
|
return {
|
||||||
path: loaded.path,
|
path: loaded.path,
|
||||||
exists: loaded.exists,
|
exists: loaded.exists,
|
||||||
config: loaded.config,
|
config: loaded.config,
|
||||||
effectiveConfig: effective.config,
|
effectiveConfig: effective.config,
|
||||||
envOverrides: piWebConfigEnvOverrides(env),
|
envOverrides: piWebConfigEnvOverrides(env, effective.config),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +83,13 @@ export function registerLocalMachineConfigRoutes(app: FastifyInstance, service:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
|
export function parseSelectedMachineConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
|
||||||
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
|
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
|
||||||
for (const key of Object.keys(value)) {
|
for (const key of Object.keys(value)) {
|
||||||
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
|
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return pickSelectedMachineConfig(parseConfigRequest(value));
|
return pickSelectedMachineConfig(parseConfigRequest(value, agentPathHost));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
|
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
|
||||||
}
|
}
|
||||||
@@ -111,13 +112,13 @@ export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB co
|
|||||||
return {
|
return {
|
||||||
path: requireResponseString(record, "path", source),
|
path: requireResponseString(record, "path", source),
|
||||||
exists: requireResponseBoolean(record, "exists", source),
|
exists: requireResponseBoolean(record, "exists", source),
|
||||||
config: parseConfigRequest(record["config"]),
|
config: parseConfigRequest(record["config"], "portable"),
|
||||||
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
|
effectiveConfig: parseConfigRequest(record["effectiveConfig"], "portable"),
|
||||||
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
|
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConfigRequest(value: unknown): PiWebConfig {
|
function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
|
||||||
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
||||||
const config: PiWebConfig = {};
|
const config: PiWebConfig = {};
|
||||||
const host = value["host"];
|
const host = value["host"];
|
||||||
@@ -130,6 +131,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
|||||||
const maxUploadBytes = value["maxUploadBytes"];
|
const maxUploadBytes = value["maxUploadBytes"];
|
||||||
const spawnSessions = value["spawnSessions"];
|
const spawnSessions = value["spawnSessions"];
|
||||||
const subsessions = value["subsessions"];
|
const subsessions = value["subsessions"];
|
||||||
|
const agent = value["agent"];
|
||||||
if (host !== undefined) {
|
if (host !== undefined) {
|
||||||
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
|
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
|
||||||
config.host = host;
|
config.host = host;
|
||||||
@@ -152,6 +154,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
|||||||
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
|
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
|
||||||
config.subsessions = subsessions;
|
config.subsessions = subsessions;
|
||||||
}
|
}
|
||||||
|
if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
|||||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||||
|
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +216,10 @@ function parseMaxUploadBytesRequest(value: unknown): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAgentRequest(value: unknown, pathHost: AgentPathHost): NonNullable<PiWebConfig["agent"]> {
|
||||||
|
return parseAgentConfig(value, "request", pathHost);
|
||||||
|
}
|
||||||
|
|
||||||
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
|
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
|
||||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
|
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
|
||||||
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
|
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
|
||||||
@@ -233,6 +241,10 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
|
|||||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||||
|
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
|
||||||
|
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
|
||||||
|
...optionalAgentDirSource(record, source),
|
||||||
|
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,13 +265,33 @@ function requireResponseBoolean(record: Record<string, unknown>, key: string, so
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
|
function optionalResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalAgentDirSource(record: Record<string, unknown>, source: string): { agentDirSource?: PiWebAgentDirEnvSource } {
|
||||||
|
const value = record["agentDirSource"];
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`);
|
||||||
|
return { agentDirSource: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
|
||||||
|
const command = config.agent?.command;
|
||||||
|
const dirEnvSource = agentDirEnvSource(env);
|
||||||
return {
|
return {
|
||||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||||
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
|
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
|
||||||
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
|
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
|
||||||
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
|
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
|
||||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||||
|
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
|
||||||
|
agentDir: hasAgentDirEnvOverride(env, command),
|
||||||
|
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
|
||||||
|
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
|
||||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||||
import { bridgeSockets } from "../webSocketBridge.js";
|
import { bridgeSockets } from "../webSocketBridge.js";
|
||||||
@@ -69,13 +70,14 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "PUT") {
|
if (method === "PUT") {
|
||||||
const patch = parseSelectedMachineConfigRequest(configPayload(body));
|
const patch = parseSelectedMachineConfigRequest(configPayload(body), "portable");
|
||||||
const currentResponse = await client.requestJson("GET", remotePath);
|
const currentResponse = await client.requestJson("GET", remotePath);
|
||||||
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
|
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
|
||||||
|
|
||||||
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
||||||
const merged = mergeSelectedMachineConfig(current.config, patch);
|
const merged = mergeSelectedMachineConfig(current.config, patch);
|
||||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
|
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
|
||||||
|
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(405).send({ error: "Method not allowed" });
|
return reply.code(405).send({ error: "Method not allowed" });
|
||||||
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
|
|||||||
return isRecord(body) ? body["config"] : undefined;
|
return isRecord(body) ? body["config"] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
|
||||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||||
|
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
|
||||||
|
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "Remote machine did not persist the requested agent profile",
|
||||||
|
machineId,
|
||||||
|
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
|
||||||
|
});
|
||||||
|
}
|
||||||
reply.code(upstream.statusCode);
|
reply.code(upstream.statusCode);
|
||||||
applySafeHeaders(reply, upstream.headers);
|
applySafeHeaders(reply, upstream.headers);
|
||||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
return reply.send(selectedMachineConfigResponse(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
|
||||||
|
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
|||||||
return health;
|
return health;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||||
const runtime = await machines.runtime(request.params.machineId);
|
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
|
||||||
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||||
return runtime;
|
return runtime;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ describe("MachineService", () => {
|
|||||||
|
|
||||||
const first = await remoteService.runtime(machine.id);
|
const first = await remoteService.runtime(machine.id);
|
||||||
const second = await remoteService.runtime(machine.id);
|
const second = await remoteService.runtime(machine.id);
|
||||||
|
const forced = await remoteService.runtime(machine.id, true);
|
||||||
|
|
||||||
expect(first).toEqual({
|
expect(first).toEqual({
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
@@ -182,7 +183,8 @@ describe("MachineService", () => {
|
|||||||
capabilities: body.capabilities,
|
capabilities: body.capabilities,
|
||||||
});
|
});
|
||||||
expect(second).toEqual(first);
|
expect(second).toEqual(first);
|
||||||
expect(requestJson).toHaveBeenCalledTimes(1);
|
expect(forced).toEqual(first);
|
||||||
|
expect(requestJson).toHaveBeenCalledTimes(2);
|
||||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||||
expect(factoryMachines).toEqual([
|
expect(factoryMachines).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -192,6 +194,7 @@ describe("MachineService", () => {
|
|||||||
token: "secret",
|
token: "secret",
|
||||||
headers: { "X-Pi-Web-Test": "yes" },
|
headers: { "X-Pi-Web-Test": "yes" },
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({ id: machine.id }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ export class MachineService {
|
|||||||
return health;
|
return health;
|
||||||
}
|
}
|
||||||
|
|
||||||
async runtime(id: string): Promise<MachineRuntime | undefined> {
|
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
|
||||||
const cached = this.runtimeCache.get(id);
|
const cached = this.runtimeCache.get(id);
|
||||||
const now = this.now().getTime();
|
const now = this.now().getTime();
|
||||||
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||||
|
|
||||||
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
||||||
if (runtime === undefined) return undefined;
|
if (runtime === undefined) return undefined;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||||
|
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
|
||||||
import type { PiPackageService } from "./piPackageService.js";
|
import type { PiPackageService } from "./piPackageService.js";
|
||||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||||
|
|
||||||
@@ -95,6 +96,15 @@ describe("registerPiPackageRoutes", () => {
|
|||||||
expect(serviceMocks.update).not.toHaveBeenCalled();
|
expect(serviceMocks.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 503 when the daemon-owned active profile is unavailable", async () => {
|
||||||
|
serviceMocks.list.mockRejectedValueOnce(new ActiveAgentProfileAccessError({ status: "unavailable", error: "connect ECONNREFUSED" }));
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json()).toEqual({ error: "Active agent profile is unavailable: connect ECONNREFUSED" });
|
||||||
|
});
|
||||||
|
|
||||||
it("returns stable 500 errors for package-manager failures", async () => {
|
it("returns stable 500 errors for package-manager failures", async () => {
|
||||||
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
|
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { PiPackageScope } from "../shared/apiTypes.js";
|
import type { PiPackageScope } from "../shared/apiTypes.js";
|
||||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
|
||||||
|
import type { PiPackageService } from "./piPackageService.js";
|
||||||
|
|
||||||
class PiPackageRequestValidationError extends Error {}
|
class PiPackageRequestValidationError extends Error {}
|
||||||
|
|
||||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
|
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService, prefix = "/api"): void {
|
||||||
const routePrefix = normalizeRoutePrefix(prefix);
|
const routePrefix = normalizeRoutePrefix(prefix);
|
||||||
|
|
||||||
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
||||||
@@ -79,7 +80,11 @@ function requireRequestObject(value: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
|
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
|
||||||
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
|
const status = error instanceof PiPackageRequestValidationError
|
||||||
|
? 400
|
||||||
|
: error instanceof ActiveAgentProfileAccessError
|
||||||
|
? 503
|
||||||
|
: 500;
|
||||||
return reply.code(status).send({ error: errorMessage(error) });
|
return reply.code(status).send({ error: errorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||||
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
|
import { type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
|
||||||
|
import { ActiveProfilePiPackageService, DefaultPiPackageService, type PiPackageManagerPort, type PiPackageService } from "./piPackageService.js";
|
||||||
|
|
||||||
function fakeManager(packages: PiPackageInfo[] = []) {
|
function fakeManager(packages: PiPackageInfo[] = []) {
|
||||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
||||||
@@ -21,6 +22,44 @@ function deferred<T = void>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("ActiveProfilePiPackageService", () => {
|
||||||
|
it("uses the daemon profile active when each package operation begins", async () => {
|
||||||
|
const getActiveAgentProfile = vi.fn<ActiveAgentProfileProvider["getActiveAgentProfile"]>()
|
||||||
|
.mockResolvedValueOnce(availableProfile("a", "/state/first"))
|
||||||
|
.mockResolvedValueOnce(availableProfile("b", "/state/second"));
|
||||||
|
const firstService = fakePiPackageService("first");
|
||||||
|
const secondService = fakePiPackageService("second");
|
||||||
|
const serviceForAgentDir = vi.fn((agentDir: string): PiPackageService => agentDir === "/state/first" ? firstService : secondService);
|
||||||
|
const service = new ActiveProfilePiPackageService({ getActiveAgentProfile }, serviceForAgentDir);
|
||||||
|
|
||||||
|
await expect(service.list()).resolves.toEqual({ packages: [{ source: "first", scope: "user", filtered: false }] });
|
||||||
|
await expect(service.install("npm:@acme/tools")).resolves.toMatchObject({ action: "install", source: "npm:@acme/tools", packages: [{ source: "second" }] });
|
||||||
|
|
||||||
|
expect(serviceForAgentDir).toHaveBeenNthCalledWith(1, "/state/first");
|
||||||
|
expect(serviceForAgentDir).toHaveBeenNthCalledWith(2, "/state/second");
|
||||||
|
expect(firstService.list).toHaveBeenCalledOnce();
|
||||||
|
expect(secondService.install).toHaveBeenCalledWith("npm:@acme/tools");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["unavailable", "invalid"] as const)("fails closed without constructing a package manager when the profile is %s", async (status) => {
|
||||||
|
const activeAgentProfile: ActiveAgentProfileProvider = {
|
||||||
|
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` }),
|
||||||
|
};
|
||||||
|
const serviceForAgentDir = vi.fn<(agentDir: string) => PiPackageService>();
|
||||||
|
const service = new ActiveProfilePiPackageService(activeAgentProfile, serviceForAgentDir);
|
||||||
|
|
||||||
|
await expect(service.list()).rejects.toMatchObject({
|
||||||
|
profileStatus: status,
|
||||||
|
message: `Active agent profile is ${status}: ${status} profile`,
|
||||||
|
});
|
||||||
|
await expect(service.install("npm:@acme/tools")).rejects.toMatchObject({
|
||||||
|
profileStatus: status,
|
||||||
|
message: `Active agent profile is ${status}: ${status} profile`,
|
||||||
|
});
|
||||||
|
expect(serviceForAgentDir).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DefaultPiPackageService", () => {
|
describe("DefaultPiPackageService", () => {
|
||||||
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
|
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
|
||||||
const fake = fakeManager([
|
const fake = fakeManager([
|
||||||
@@ -201,3 +240,26 @@ describe("DefaultPiPackageService", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function availableProfile(revisionCharacter: string, dir: string) {
|
||||||
|
return {
|
||||||
|
status: "available" as const,
|
||||||
|
profile: {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
revision: `sha256:${revisionCharacter.repeat(64)}`,
|
||||||
|
command: `${revisionCharacter}-agent`,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakePiPackageService(source: string) {
|
||||||
|
const packages = [{ source, scope: "user" as const, filtered: false }];
|
||||||
|
return {
|
||||||
|
list: vi.fn(() => Promise.resolve({ packages })),
|
||||||
|
install: vi.fn((installedSource: string) => Promise.resolve({ action: "install" as const, source: installedSource, packages })),
|
||||||
|
remove: vi.fn((removedSource: string, scope: "user" | "project" = "user") => Promise.resolve({ action: "remove" as const, source: removedSource, scope, removed: true, packages })),
|
||||||
|
update: vi.fn((updatedSource?: string) => Promise.resolve({ action: "update" as const, ...(updatedSource === undefined ? {} : { source: updatedSource }), packages })),
|
||||||
|
} satisfies PiPackageService;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
|
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
|
||||||
|
import { requireActiveAgentProfile, type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
|
||||||
|
|
||||||
export interface PiPackageManagerPort {
|
export interface PiPackageManagerPort {
|
||||||
listConfiguredPackages(): PiPackageInfo[];
|
listConfiguredPackages(): PiPackageInfo[];
|
||||||
@@ -16,6 +17,47 @@ export interface PiPackageService {
|
|||||||
update(source?: string): Promise<PiPackageMutationResponse>;
|
update(source?: string): Promise<PiPackageMutationResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PiPackageServiceForAgentDir = (agentDir: string) => PiPackageService;
|
||||||
|
|
||||||
|
export class ActiveProfilePiPackageService implements PiPackageService {
|
||||||
|
private mutationQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly activeAgentProfile: ActiveAgentProfileProvider,
|
||||||
|
private readonly serviceForAgentDir: PiPackageServiceForAgentDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<PiPackagesResponse> {
|
||||||
|
return await this.withActiveService((service) => service.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
install(source: string): Promise<PiPackageMutationResponse> {
|
||||||
|
return this.enqueueMutation((service) => service.install(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse> {
|
||||||
|
return this.enqueueMutation((service) => service.remove(source, scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(source?: string): Promise<PiPackageMutationResponse> {
|
||||||
|
return this.enqueueMutation((service) => service.update(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueMutation(operation: (service: PiPackageService) => Promise<PiPackageMutationResponse>): Promise<PiPackageMutationResponse> {
|
||||||
|
const queuedMutation = this.mutationQueue.then(() => this.withActiveService(operation));
|
||||||
|
this.mutationQueue = queuedMutation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return queuedMutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withActiveService<T>(operation: (service: PiPackageService) => Promise<T>): Promise<T> {
|
||||||
|
const profile = await requireActiveAgentProfile(this.activeAgentProfile);
|
||||||
|
return await operation(this.serviceForAgentDir(profile.dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class DefaultPiPackageService implements PiPackageService {
|
export class DefaultPiPackageService implements PiPackageService {
|
||||||
private mutationQueue: Promise<void> = Promise.resolve();
|
private mutationQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -84,7 +126,11 @@ export class DefaultPiPackageService implements PiPackageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
|
export function createActiveProfilePiPackageService(activeAgentProfile: ActiveAgentProfileProvider, cwd = process.cwd()): PiPackageService {
|
||||||
|
return new ActiveProfilePiPackageService(activeAgentProfile, (agentDir) => createDefaultPiPackageService(cwd, agentDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultPiPackageService(cwd: string, agentDir: string): PiPackageService {
|
||||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||||
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||||
return new DefaultPiPackageService({
|
return new DefaultPiPackageService({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
|
||||||
import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js";
|
import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js";
|
||||||
|
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -133,6 +134,43 @@ describe("PiWebPluginService", () => {
|
|||||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the active agent directory on every Pi package plugin discovery", async () => {
|
||||||
|
const packageDir = join(tempDir, "pkg");
|
||||||
|
const initialAgentDir = join(tempDir, "initial-agent");
|
||||||
|
const updatedAgentDir = join(tempDir, "updated-agent");
|
||||||
|
let activeAgentDir = initialAgentDir;
|
||||||
|
await writePlugin(packageDir, {
|
||||||
|
packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } },
|
||||||
|
files: { "dist/plugin.js": "export default {};" },
|
||||||
|
});
|
||||||
|
await mkdir(initialAgentDir, { recursive: true });
|
||||||
|
await mkdir(updatedAgentDir, { recursive: true });
|
||||||
|
await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8");
|
||||||
|
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDirProvider: () => activeAgentDir });
|
||||||
|
|
||||||
|
await expect(service.manifest()).resolves.toEqual({ plugins: [] });
|
||||||
|
|
||||||
|
activeAgentDir = updatedAgentDir;
|
||||||
|
|
||||||
|
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails complete package-backed discovery closed while keeping known local assets independent", async () => {
|
||||||
|
const pluginDir = join(tempDir, "plugins", "local-only");
|
||||||
|
await writePlugin(pluginDir, {
|
||||||
|
packageJson: { piWeb: { plugins: [{ id: "local-only", module: "pi-web-plugin.js" }] } },
|
||||||
|
files: { "pi-web-plugin.js": "export default {};" },
|
||||||
|
});
|
||||||
|
const profileError = new ActiveAgentProfileAccessError({ status: "invalid", error: "missing descriptor" });
|
||||||
|
const service = new PiWebPluginService({
|
||||||
|
roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }],
|
||||||
|
agentDirProvider: () => { throw profileError; },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.manifest()).rejects.toBe(profileError);
|
||||||
|
await expect(service.readAsset("local-only", "pi-web-plugin.js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
||||||
const agentDir = join(tempDir, "agent");
|
const agentDir = join(tempDir, "agent");
|
||||||
const firstPackageDir = join(tempDir, "first-package");
|
const firstPackageDir = join(tempDir, "first-package");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||||
import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
|
import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
|
||||||
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
|
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
|
||||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||||
@@ -46,8 +46,9 @@ interface PiWebPluginServiceOptions {
|
|||||||
roots?: LocalPluginRoot[];
|
roots?: LocalPluginRoot[];
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
agentDirProvider?: () => string | Promise<string>;
|
||||||
packageProvider?: PiPackageProvider | false;
|
packageProvider?: PiPackageProvider | false;
|
||||||
configProvider?: () => PiWebConfig;
|
configProvider?: () => PiWebConfig | Promise<PiWebConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalPluginRoot {
|
interface LocalPluginRoot {
|
||||||
@@ -69,7 +70,10 @@ interface PiWebPluginEntry {
|
|||||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||||
|
|
||||||
export class DefaultPiPackageProvider implements PiPackageProvider {
|
export class DefaultPiPackageProvider implements PiPackageProvider {
|
||||||
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
|
constructor(
|
||||||
|
private readonly cwd: string,
|
||||||
|
private readonly agentDir: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
listPackages(): ConfiguredPiPackage[] {
|
listPackages(): ConfiguredPiPackage[] {
|
||||||
return this.createPackageManager().listConfiguredPackages();
|
return this.createPackageManager().listConfiguredPackages();
|
||||||
@@ -90,14 +94,22 @@ export class DefaultPiPackageProvider implements PiPackageProvider {
|
|||||||
|
|
||||||
export class PiWebPluginService {
|
export class PiWebPluginService {
|
||||||
private readonly roots: LocalPluginRoot[];
|
private readonly roots: LocalPluginRoot[];
|
||||||
private readonly packageProvider: PiPackageProvider | undefined;
|
private readonly agentDir: string | undefined;
|
||||||
private readonly configProvider: () => PiWebConfig;
|
private readonly agentDirProvider: (() => string | Promise<string>) | undefined;
|
||||||
|
private readonly staticPackageProvider: PiPackageProvider | undefined;
|
||||||
|
private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined;
|
||||||
|
private readonly configProvider: () => PiWebConfig | Promise<PiWebConfig>;
|
||||||
|
|
||||||
constructor(options: PiWebPluginServiceOptions = {}) {
|
constructor(options: PiWebPluginServiceOptions = {}) {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const agentDir = options.agentDir ?? getAgentDir();
|
|
||||||
this.roots = options.roots ?? defaultPluginRoots(cwd);
|
this.roots = options.roots ?? defaultPluginRoots(cwd);
|
||||||
this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir);
|
this.agentDir = options.agentDir;
|
||||||
|
this.agentDirProvider = options.agentDirProvider;
|
||||||
|
const packageProvider = options.packageProvider;
|
||||||
|
this.staticPackageProvider = packageProvider === false || packageProvider === undefined ? undefined : packageProvider;
|
||||||
|
this.packageProviderForAgentDir = packageProvider === false || packageProvider !== undefined
|
||||||
|
? undefined
|
||||||
|
: (agentDir) => new DefaultPiPackageProvider(cwd, agentDir);
|
||||||
this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
|
this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,13 +122,14 @@ export class PiWebPluginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async plugins(): Promise<PiWebPluginsResponse> {
|
async plugins(): Promise<PiWebPluginsResponse> {
|
||||||
const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]);
|
const config = await this.configProvider();
|
||||||
|
const plugins = await this.discoverPlugins();
|
||||||
return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
|
return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
|
async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
|
||||||
if (!isPiWebPluginId(pluginId)) return undefined;
|
if (!isPiWebPluginId(pluginId)) return undefined;
|
||||||
const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId);
|
const plugin = await this.findPlugin(pluginId);
|
||||||
if (plugin === undefined) return undefined;
|
if (plugin === undefined) return undefined;
|
||||||
|
|
||||||
const resolved = resolve(plugin.root, assetPath);
|
const resolved = resolve(plugin.root, assetPath);
|
||||||
@@ -146,12 +159,36 @@ export class PiWebPluginService {
|
|||||||
private async discoverPlugins(): Promise<PluginRecord[]> {
|
private async discoverPlugins(): Promise<PluginRecord[]> {
|
||||||
const records = new Map<string, PluginRecord>();
|
const records = new Map<string, PluginRecord>();
|
||||||
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
|
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
|
||||||
if (this.packageProvider !== undefined) {
|
const packageProvider = await this.currentPackageProvider();
|
||||||
for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin);
|
if (packageProvider !== undefined) {
|
||||||
|
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
|
||||||
}
|
}
|
||||||
return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
|
return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findPlugin(pluginId: string): Promise<PluginRecord | undefined> {
|
||||||
|
const localPlugin = (await this.discoverLocalPlugins()).find((candidate) => candidate.id === pluginId);
|
||||||
|
if (localPlugin !== undefined) return localPlugin;
|
||||||
|
|
||||||
|
const packageProvider = await this.currentPackageProvider();
|
||||||
|
if (packageProvider === undefined) return undefined;
|
||||||
|
const records = new Map<string, PluginRecord>();
|
||||||
|
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
|
||||||
|
return records.get(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentPackageProvider(): Promise<PiPackageProvider | undefined> {
|
||||||
|
if (this.staticPackageProvider !== undefined) return this.staticPackageProvider;
|
||||||
|
if (this.packageProviderForAgentDir === undefined) return undefined;
|
||||||
|
return this.packageProviderForAgentDir(await this.currentAgentDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentAgentDir(): Promise<string> {
|
||||||
|
if (this.agentDirProvider !== undefined) return await this.agentDirProvider();
|
||||||
|
if (this.agentDir !== undefined) return this.agentDir;
|
||||||
|
throw new Error("Pi package plugin discovery requires an explicit active agent directory");
|
||||||
|
}
|
||||||
|
|
||||||
private async discoverLocalPlugins(): Promise<PluginRecord[]> {
|
private async discoverLocalPlugins(): Promise<PluginRecord[]> {
|
||||||
const plugins: PluginRecord[] = [];
|
const plugins: PluginRecord[] = [];
|
||||||
for (const root of this.roots) plugins.push(...await discoverLocalRoot(root));
|
for (const root of this.roots) plugins.push(...await discoverLocalRoot(root));
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js";
|
||||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||||
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
import type { PiWebComponentStatus, PiWebRuntimeComponent } from "../shared/apiTypes.js";
|
||||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||||
|
|
||||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||||
@@ -14,6 +14,7 @@ const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
|
|||||||
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
|
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
|
||||||
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
|
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
|
||||||
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
|
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
|
||||||
|
const originalAgentDir = process.env["PI_WEB_AGENT_DIR"];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
|
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
|
||||||
@@ -23,6 +24,7 @@ afterEach(() => {
|
|||||||
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
|
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
|
||||||
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
|
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
|
||||||
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
|
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
|
||||||
|
restoreEnv("PI_WEB_AGENT_DIR", originalAgentDir);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,6 +53,50 @@ describe("PI WEB status", () => {
|
|||||||
expect(status).not.toHaveProperty("release");
|
expect(status).not.toHaveProperty("release");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects session daemon package installs from the configured agent dir for runtime responses", async () => {
|
||||||
|
disableDockerRuntimeEnv();
|
||||||
|
const agentDir = await tempHome();
|
||||||
|
try {
|
||||||
|
await installConfiguredPiWebPackage(agentDir);
|
||||||
|
const daemon = daemonWithRuntime({
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
runtimeVersion: "1.202605.7",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getPiWebVersionStatus(daemon, { activeAgentProfile: activeProfile("a", "alt-agent", agentDir) });
|
||||||
|
|
||||||
|
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" });
|
||||||
|
} finally {
|
||||||
|
await rm(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to the web process environment when no active profile is available", async () => {
|
||||||
|
disableDockerRuntimeEnv();
|
||||||
|
const agentDir = await tempHome();
|
||||||
|
try {
|
||||||
|
await installConfiguredPiWebPackage(agentDir);
|
||||||
|
process.env["PI_WEB_AGENT_DIR"] = agentDir;
|
||||||
|
const daemon = daemonWithRuntime({
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
runtimeVersion: "1.202605.7",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getPiWebVersionStatus(daemon);
|
||||||
|
|
||||||
|
expect(status.components.web.installation?.kind).not.toBe("pi-package");
|
||||||
|
expect(status.components.sessiond.installation?.kind).not.toBe("pi-package");
|
||||||
|
} finally {
|
||||||
|
await rm(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reports web-only capabilities from the web runtime", async () => {
|
it("reports web-only capabilities from the web runtime", async () => {
|
||||||
const daemon = daemonWithComponent({
|
const daemon = daemonWithComponent({
|
||||||
component: "sessiond",
|
component: "sessiond",
|
||||||
@@ -63,10 +109,34 @@ describe("PI WEB status", () => {
|
|||||||
|
|
||||||
const runtime = await getPiWebRuntime(daemon);
|
const runtime = await getPiWebRuntime(daemon);
|
||||||
|
|
||||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
|
||||||
|
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the daemon-owned active agent profile through the web runtime response", async () => {
|
||||||
|
const activeAgentProfile = {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: "/opt/acme-agent/state",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
const daemon = daemonWithRuntime({
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
runtimeVersion: "1.202605.7",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
activeAgentProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await getPiWebRuntime(daemon);
|
||||||
|
|
||||||
|
expect(runtime.components.sessiond.activeAgentProfile).toEqual(activeAgentProfile);
|
||||||
|
expect(runtime.components.web.activeAgentProfile).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bypasses cached npm release data for a forced check", async () => {
|
it("bypasses cached npm release data for a forced check", async () => {
|
||||||
@@ -117,6 +187,50 @@ describe("PI WEB status", () => {
|
|||||||
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses Pi package update planning without an active companion command", async () => {
|
||||||
|
const hasCommand = vi.fn(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
const updateCommand = await updateCommandFor(
|
||||||
|
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||||
|
"pi-web restart",
|
||||||
|
{ activeAgentProfile: undefined, hasCommand },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateCommand).toBeUndefined();
|
||||||
|
expect(hasCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves and shell-quotes the active state profile in Pi-package update commands", async () => {
|
||||||
|
const command = "/tmp/agent's/pi";
|
||||||
|
const dir = "/tmp/profile's/state";
|
||||||
|
const updateCommand = await updateCommandFor(
|
||||||
|
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||||
|
"pi-web restart",
|
||||||
|
{
|
||||||
|
activeAgentProfile: activeProfile("a", command, dir),
|
||||||
|
hasCommand: (candidate) => Promise.resolve(candidate === command),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateCommand).toBe("PI_CODING_AGENT_DIR='/tmp/profile'\\''s/state' '/tmp/agent'\\''s/pi' update 'npm:@jmfederico/pi-web' && pi-web restart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
activeProfile("a", "acme-agent", "/opt/acme/state"),
|
||||||
|
activeProfile("b", "pi", "relative/state"),
|
||||||
|
])("suppresses Pi-package updates when the active companion profile cannot be represented safely", async (profile) => {
|
||||||
|
const hasCommand = vi.fn(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
const updateCommand = await updateCommandFor(
|
||||||
|
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||||
|
"pi-web restart",
|
||||||
|
{ activeAgentProfile: profile, hasCommand },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateCommand).toBeUndefined();
|
||||||
|
expect(hasCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
|
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
|
||||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||||
disableDockerRuntimeEnv();
|
disableDockerRuntimeEnv();
|
||||||
@@ -219,6 +333,16 @@ describe("PI WEB status", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function activeProfile(revisionCharacter: string, command: string, dir: string) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
revision: `sha256:${revisionCharacter.repeat(64)}`,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function npmVersionResponse(version: string): Response {
|
function npmVersionResponse(version: string): Response {
|
||||||
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
|
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
}
|
}
|
||||||
@@ -233,6 +357,16 @@ function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClie
|
|||||||
return daemon;
|
return daemon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function daemonWithRuntime(component: PiWebRuntimeComponent): SessionDaemonClient {
|
||||||
|
const daemon = new SessionDaemonClient();
|
||||||
|
vi.spyOn(daemon, "request").mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(component),
|
||||||
|
});
|
||||||
|
return daemon;
|
||||||
|
}
|
||||||
|
|
||||||
function staleLocalSessiond(): PiWebComponentStatus {
|
function staleLocalSessiond(): PiWebComponentStatus {
|
||||||
return {
|
return {
|
||||||
component: "sessiond",
|
component: "sessiond",
|
||||||
@@ -255,6 +389,10 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis
|
|||||||
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
|
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
|
||||||
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
async function installExecutable(dir: string, name: string): Promise<void> {
|
async function installExecutable(dir: string, name: string): Promise<void> {
|
||||||
const path = join(dir, name);
|
const path = join(dir, name);
|
||||||
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
|
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
|
||||||
|
|||||||
+27
-21
@@ -5,12 +5,13 @@ import { promisify } from "node:util";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
import type { ActiveAgentProfileDescriptor, PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||||
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
||||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
import { isHostAbsoluteAgentDir, isPiCompanionCommand, isSafeAgentCommandForHost, PI_CODING_AGENT_DIR_ENV } from "../config.js";
|
||||||
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
|
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
|
||||||
|
|
||||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||||
@@ -76,6 +77,8 @@ interface PiWebStatusDaemon {
|
|||||||
|
|
||||||
export interface PiWebStatusOptions {
|
export interface PiWebStatusOptions {
|
||||||
forceReleaseCheck?: boolean;
|
forceReleaseCheck?: boolean;
|
||||||
|
activeAgentProfile?: ActiveAgentProfileDescriptor;
|
||||||
|
hasCommand?: (command: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
|
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
|
||||||
@@ -102,10 +105,10 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
|
export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
|
||||||
const [installed, installation] = await Promise.all([
|
const [installed, installation] = await Promise.all([
|
||||||
readInstalledPackageInfo(),
|
readInstalledPackageInfo(),
|
||||||
detectPiWebInstallation(),
|
detectPiWebInstallation(options.activeAgentProfile?.dir),
|
||||||
]);
|
]);
|
||||||
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
|
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
|
||||||
const installedVersion = installed?.version;
|
const installedVersion = installed?.version;
|
||||||
@@ -120,10 +123,10 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebVersionResponse> {
|
||||||
const [web, sessiond] = await Promise.all([
|
const [web, sessiond] = await Promise.all([
|
||||||
getPiWebComponentStatus("web"),
|
getPiWebComponentStatus("web", options),
|
||||||
getSessiondComponentStatus(daemon),
|
getSessiondComponentStatus(daemon, options),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
packageName: PI_WEB_PACKAGE_NAME,
|
packageName: PI_WEB_PACKAGE_NAME,
|
||||||
@@ -133,11 +136,11 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
|
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
|
||||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
const versionStatus = await getPiWebVersionStatus(daemon, options);
|
||||||
const { web, sessiond } = versionStatus.components;
|
const { web, sessiond } = versionStatus.components;
|
||||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
|
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
|
||||||
const components = { web, sessiond };
|
const components = { web, sessiond };
|
||||||
const commands = await commandsFor(components);
|
const commands = await commandsFor(components, { activeAgentProfile: options.activeAgentProfile, hasCommand: options.hasCommand ?? hasCommand });
|
||||||
const messages = buildMessages(components, release, commands);
|
const messages = buildMessages(components, release, commands);
|
||||||
return {
|
return {
|
||||||
...versionStatus,
|
...versionStatus,
|
||||||
@@ -191,13 +194,15 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
|
|||||||
return { name, version, path };
|
return { name, version, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
async function detectPiWebInstallation(agentDir?: string): Promise<PiWebInstallationInfo> {
|
||||||
const docker = detectDockerInstallation();
|
const docker = detectDockerInstallation();
|
||||||
if (docker !== undefined) return docker;
|
if (docker !== undefined) return docker;
|
||||||
const root = packageRootPath();
|
const root = packageRootPath();
|
||||||
const realRoot = await realPathOrSelf(root);
|
const realRoot = await realPathOrSelf(root);
|
||||||
const piPackage = await detectPiPackageInstallation(realRoot, root);
|
if (agentDir !== undefined) {
|
||||||
|
const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir);
|
||||||
if (piPackage !== undefined) return piPackage;
|
if (piPackage !== undefined) return piPackage;
|
||||||
|
}
|
||||||
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
|
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
|
||||||
if (npmGlobal !== undefined) return npmGlobal;
|
if (npmGlobal !== undefined) return npmGlobal;
|
||||||
return { kind: "local", path: root };
|
return { kind: "local", path: root };
|
||||||
@@ -243,9 +248,8 @@ function isTruthyEnv(key: string): boolean {
|
|||||||
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
|
async function detectPiPackageInstallation(realRoot: string, displayPath: string, agentDir: string): Promise<PiWebInstallationInfo | undefined> {
|
||||||
try {
|
try {
|
||||||
const agentDir = getAgentDir();
|
|
||||||
const packageManager = new DefaultPackageManager({
|
const packageManager = new DefaultPackageManager({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
agentDir,
|
agentDir,
|
||||||
@@ -313,7 +317,7 @@ async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
|
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
|
||||||
try {
|
try {
|
||||||
const upstream = await daemon.request("GET", "/runtime");
|
const upstream = await daemon.request("GET", "/runtime");
|
||||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
||||||
@@ -324,7 +328,7 @@ async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<Pi
|
|||||||
if (legacyVersion !== undefined) return legacyVersion;
|
if (legacyVersion !== undefined) return legacyVersion;
|
||||||
const runtime = parsePiWebRuntimeComponent(parsed);
|
const runtime = parsePiWebRuntimeComponent(parsed);
|
||||||
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
|
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
|
||||||
const status = await getPiWebComponentStatus("sessiond");
|
const status = await getPiWebComponentStatus("sessiond", options);
|
||||||
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
|
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return unavailableSessiond(error instanceof Error ? error.message : String(error));
|
return unavailableSessiond(error instanceof Error ? error.message : String(error));
|
||||||
@@ -412,7 +416,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
|||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
|
async function commandsFor(components: PiWebStatusResponse["components"], options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
|
||||||
const installation = preferredInstallation(components);
|
const installation = preferredInstallation(components);
|
||||||
if (installation?.kind === "docker") return dockerCommands(installation);
|
if (installation?.kind === "docker") return dockerCommands(installation);
|
||||||
|
|
||||||
@@ -424,7 +428,7 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi
|
|||||||
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
|
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
|
||||||
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
|
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
|
||||||
const status = serviceCommands.status ?? cliCommands.status;
|
const status = serviceCommands.status ?? cliCommands.status;
|
||||||
const update = await updateCommandFor(installation, restart);
|
const update = await updateCommandFor(installation, restart, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(update === undefined ? {} : { update }),
|
...(update === undefined ? {} : { update }),
|
||||||
@@ -463,11 +467,13 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
|
|||||||
return cliCommands.restart ?? serviceCommands.restart;
|
return cliCommands.restart ?? serviceCommands.restart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
|
export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> {
|
||||||
if (restartCommand === undefined) return undefined;
|
if (restartCommand === undefined) return undefined;
|
||||||
if (installation?.kind === "pi-package") {
|
if (installation?.kind === "pi-package") {
|
||||||
if (!(await hasCommand("pi"))) return undefined;
|
const profile = options.activeAgentProfile;
|
||||||
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
|
if (profile === undefined || !isSafeAgentCommandForHost(profile.command) || !isHostAbsoluteAgentDir(profile.dir) || !isPiCompanionCommand(profile.command)) return undefined;
|
||||||
|
if (!(await options.hasCommand(profile.command))) return undefined;
|
||||||
|
return `${PI_CODING_AGENT_DIR_ENV}=${shellQuote(profile.dir)} ${shellQuote(profile.command)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`;
|
||||||
}
|
}
|
||||||
if (installation?.kind === "local" && installation.path !== undefined) {
|
if (installation?.kind === "local" && installation.path !== undefined) {
|
||||||
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
|
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
|
||||||
@@ -547,7 +553,7 @@ async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasCommand(command: string): Promise<boolean> {
|
function hasCommand(command: string): Promise<boolean> {
|
||||||
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
|
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
|
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
|
||||||
|
|||||||
@@ -113,6 +113,42 @@ describe("createPiWebStatusCache", () => {
|
|||||||
expect(load).toHaveBeenCalledTimes(3);
|
expect(load).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads again after a fresh cache is invalidated", async () => {
|
||||||
|
const load = vi.fn()
|
||||||
|
.mockResolvedValueOnce(status("first"))
|
||||||
|
.mockResolvedValueOnce(status("second"));
|
||||||
|
const cache = createPiWebStatusCache(load);
|
||||||
|
|
||||||
|
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||||
|
cache.invalidate();
|
||||||
|
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
|
||||||
|
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons an in-flight load when invalidated", async () => {
|
||||||
|
const firstLoad = createDeferred<PiWebStatusResponse>();
|
||||||
|
const secondLoad = createDeferred<PiWebStatusResponse>();
|
||||||
|
const load = vi.fn()
|
||||||
|
.mockImplementationOnce(() => firstLoad.promise)
|
||||||
|
.mockImplementationOnce(() => secondLoad.promise);
|
||||||
|
const cache = createPiWebStatusCache(load);
|
||||||
|
|
||||||
|
const first = cache.get();
|
||||||
|
await waitForMicrotasks();
|
||||||
|
cache.invalidate();
|
||||||
|
const second = cache.get();
|
||||||
|
await waitForMicrotasks();
|
||||||
|
|
||||||
|
secondLoad.resolve(status("second"));
|
||||||
|
await expect(second).resolves.toMatchObject({ generatedAt: "second" });
|
||||||
|
firstLoad.resolve(status("first"));
|
||||||
|
await expect(first).resolves.toMatchObject({ generatedAt: "first" });
|
||||||
|
|
||||||
|
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("deduplicates concurrent cold loads", async () => {
|
it("deduplicates concurrent cold loads", async () => {
|
||||||
const deferred = createDeferred<PiWebStatusResponse>();
|
const deferred = createDeferred<PiWebStatusResponse>();
|
||||||
const load = vi.fn(() => deferred.promise);
|
const load = vi.fn(() => deferred.promise);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface PiWebStatusCacheRefreshOptions {
|
|||||||
export interface PiWebStatusCache {
|
export interface PiWebStatusCache {
|
||||||
get(): Promise<PiWebStatusResponse>;
|
get(): Promise<PiWebStatusResponse>;
|
||||||
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
|
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
|
||||||
|
invalidate(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||||
@@ -56,5 +57,10 @@ export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptio
|
|||||||
return refresh();
|
return refresh();
|
||||||
},
|
},
|
||||||
refresh,
|
refresh,
|
||||||
|
invalidate(): void {
|
||||||
|
cached = undefined;
|
||||||
|
loadSequence += 1;
|
||||||
|
pending = undefined;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-20
@@ -9,6 +9,7 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js";
|
|||||||
import { AuthService } from "./sessions/authService.js";
|
import { AuthService } from "./sessions/authService.js";
|
||||||
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||||
|
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
|
||||||
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||||
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
|
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
|
||||||
import { ProjectService } from "./projects/projectService.js";
|
import { ProjectService } from "./projects/projectService.js";
|
||||||
@@ -19,11 +20,18 @@ import { TerminalService } from "./terminals/terminalService.js";
|
|||||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||||
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
|
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes } from "../config.js";
|
||||||
|
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
|
||||||
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
||||||
|
|
||||||
const { config } = effectivePiWebConfig();
|
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
|
||||||
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
|
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
|
||||||
|
const activeAgentProfile = createActiveAgentProfileDescriptor({
|
||||||
|
command: config.agent.command,
|
||||||
|
dir: config.agent.dir,
|
||||||
|
sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command),
|
||||||
|
});
|
||||||
|
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(daemonEnvironment, config) });
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
await runSessionDaemonStartup({
|
await runSessionDaemonStartup({
|
||||||
@@ -31,44 +39,51 @@ await runSessionDaemonStartup({
|
|||||||
createRuntime() {
|
createRuntime() {
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||||
const auth = new AuthService();
|
const auth = new AuthService({ agentDir: activeAgentProfile.dir });
|
||||||
const spawnTargets = spawnSessionsEnabled(process.env, config)
|
const spawnTargets = config.spawnSessions
|
||||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sessions = new PiSessionService(eventHub, {
|
const sessions = new PiSessionService(eventHub, {
|
||||||
modelRegistry: auth.modelRegistry,
|
modelRegistry: auth.modelRegistry,
|
||||||
|
agentDir: activeAgentProfile.dir,
|
||||||
workspaceActivity,
|
workspaceActivity,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||||
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
|
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
||||||
|
sessionManager: createPiSessionManagerGateway({
|
||||||
|
agentDir: activeAgentProfile.dir,
|
||||||
|
env: daemonEnvironment,
|
||||||
|
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||||
return { eventHub, workspaceActivity, auth, sessions, terminals };
|
const runtimeComponent = Object.freeze({
|
||||||
|
...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
|
||||||
|
activeAgentProfile,
|
||||||
|
});
|
||||||
|
return { eventHub, workspaceActivity, auth, sessions, terminals, activeAgentProfile, runtimeComponent };
|
||||||
},
|
},
|
||||||
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals }) {
|
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) {
|
||||||
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
||||||
registerAuthRoutes(app, auth);
|
registerAuthRoutes(app, auth);
|
||||||
registerSessionRoutes(app, sessions, eventHub);
|
registerSessionRoutes(app, sessions, eventHub);
|
||||||
registerTerminalRoutes(app, terminals);
|
registerTerminalRoutes(app, terminals);
|
||||||
|
|
||||||
app.get("/health", () => {
|
app.get("/health", () => ({
|
||||||
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
|
|
||||||
return {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
activeSessions: sessions.activeCount(),
|
activeSessions: sessions.activeCount(),
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
version: {
|
version: {
|
||||||
component: runtime.component,
|
component: runtimeComponent.component,
|
||||||
label: runtime.label,
|
label: runtimeComponent.label,
|
||||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }),
|
||||||
stale: false,
|
stale: false,
|
||||||
available: runtime.available,
|
available: runtimeComponent.available,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
|
app.get("/runtime", () => runtimeComponent);
|
||||||
},
|
},
|
||||||
async listen({ auth, sessions, terminals }) {
|
async listen({ auth, sessions, terminals }) {
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
@@ -85,9 +100,9 @@ await runSessionDaemonStartup({
|
|||||||
process.once("SIGINT", (signal) => { void shutdown(signal); });
|
process.once("SIGINT", (signal) => { void shutdown(signal); });
|
||||||
process.once("SIGTERM", (signal) => { void shutdown(signal); });
|
process.once("SIGTERM", (signal) => { void shutdown(signal); });
|
||||||
|
|
||||||
const portValue = process.env["PI_WEB_SESSIOND_PORT"];
|
const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"];
|
||||||
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
|
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
|
||||||
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
|
const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
|
||||||
|
|
||||||
if (port !== undefined) {
|
if (port !== undefined) {
|
||||||
await app.listen({ port, host });
|
await app.listen({ port, host });
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OAuthFlowState } from "../../shared/apiTypes.js";
|
import type { OAuthFlowState } from "../../shared/apiTypes.js";
|
||||||
import { AuthService, type AuthChange } from "./authService.js";
|
import { AuthService, type AuthChange } from "./authService.js";
|
||||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
describe("AuthService", () => {
|
describe("AuthService", () => {
|
||||||
it("saves API keys and emits a global auth change", () => {
|
it("saves API keys and emits a global auth change", () => {
|
||||||
const { auth, authStorage, changes } = createAuthService();
|
const { auth, authStorage, changes } = createAuthService();
|
||||||
@@ -33,6 +42,16 @@ describe("AuthService", () => {
|
|||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores credentials in the configured agent directory", async () => {
|
||||||
|
const agentDir = await tempAgentDir();
|
||||||
|
const auth = new AuthService({ agentDir });
|
||||||
|
|
||||||
|
auth.saveApiKey("anthropic", "sk-test");
|
||||||
|
|
||||||
|
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
|
||||||
|
auth.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes auth state after OAuth login completes", () => {
|
it("refreshes auth state after OAuth login completes", () => {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const authStorage = AuthStorage.inMemory();
|
||||||
const modelRegistry = ModelRegistry.create(authStorage);
|
const modelRegistry = ModelRegistry.create(authStorage);
|
||||||
@@ -76,6 +95,12 @@ function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}
|
|||||||
return { auth, authStorage, changes };
|
return { auth, authStorage, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tempAgentDir(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
|
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
|
||||||
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
||||||
disposed = false;
|
disposed = false;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
|
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
|
||||||
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
|
||||||
@@ -11,17 +12,23 @@ type AuthChangeListener = (change: AuthChange) => void;
|
|||||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||||
|
|
||||||
export interface AuthServiceDependencies {
|
export interface AuthServiceDependencies {
|
||||||
|
agentDir?: string;
|
||||||
modelRegistry?: ModelRegistryInstance;
|
modelRegistry?: ModelRegistryInstance;
|
||||||
authFlows?: OAuthLoginFlowService;
|
authFlows?: OAuthLoginFlowService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
|
||||||
|
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
|
||||||
|
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
readonly modelRegistry: ModelRegistryInstance;
|
readonly modelRegistry: ModelRegistryInstance;
|
||||||
private readonly authFlows: OAuthLoginFlowService;
|
private readonly authFlows: OAuthLoginFlowService;
|
||||||
private readonly listeners = new Set<AuthChangeListener>();
|
private readonly listeners = new Set<AuthChangeListener>();
|
||||||
|
|
||||||
constructor(deps: AuthServiceDependencies = {}) {
|
constructor(deps: AuthServiceDependencies = {}) {
|
||||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
|
||||||
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
|
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { agentSessionDirEnvKeys } from "../../config.js";
|
||||||
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
|
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
|
||||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||||
import type { PiSessionManager } from "./piSessionService.js";
|
import type { PiSessionManager } from "./piSessionService.js";
|
||||||
@@ -24,7 +25,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
describe("SessionDirResolver", () => {
|
describe("SessionDirResolver", () => {
|
||||||
it("uses Pi default session storage when no Pi override is configured", () => {
|
it("uses Pi default session storage when no Pi override is configured", () => {
|
||||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
const resolver = new SessionDirResolver(piProfileOptions());
|
||||||
|
|
||||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
|
||||||
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
|
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
|
||||||
@@ -34,7 +35,7 @@ describe("SessionDirResolver", () => {
|
|||||||
await mkdir(agentDir, { recursive: true });
|
await mkdir(agentDir, { recursive: true });
|
||||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
const resolver = new SessionDirResolver(piProfileOptions());
|
||||||
|
|
||||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
|
||||||
});
|
});
|
||||||
@@ -45,7 +46,7 @@ describe("SessionDirResolver", () => {
|
|||||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
|
||||||
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
|
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
const resolver = new SessionDirResolver({ agentDir, env: {} });
|
const resolver = new SessionDirResolver(piProfileOptions());
|
||||||
|
|
||||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
|
||||||
});
|
});
|
||||||
@@ -55,10 +56,32 @@ describe("SessionDirResolver", () => {
|
|||||||
await mkdir(agentDir, { recursive: true });
|
await mkdir(agentDir, { recursive: true });
|
||||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } });
|
const resolver = new SessionDirResolver(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envDir }));
|
||||||
|
|
||||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses PI WEB sessionDir environment overrides before settings", async () => {
|
||||||
|
const envDir = join(tempDir, "pi-web-env-sessions");
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
const resolver = new SessionDirResolver(piProfileOptions({ PI_WEB_AGENT_SESSION_DIR: envDir }));
|
||||||
|
|
||||||
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots the daemon epoch's injected session-directory environment", () => {
|
||||||
|
const firstDir = join(tempDir, "first-env-sessions");
|
||||||
|
const env = { PI_WEB_AGENT_SESSION_DIR: firstDir };
|
||||||
|
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
|
||||||
|
const resolver = new SessionDirResolver({ agentDir, env, sessionDirEnvKeys });
|
||||||
|
|
||||||
|
env.PI_WEB_AGENT_SESSION_DIR = join(tempDir, "mutated-env-sessions");
|
||||||
|
sessionDirEnvKeys[0] = "OTHER_SESSION_DIR";
|
||||||
|
|
||||||
|
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: firstDir, usesConfiguredSessionDir: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Pi session manager gateway", () => {
|
describe("Pi session manager gateway", () => {
|
||||||
@@ -66,7 +89,7 @@ describe("Pi session manager gateway", () => {
|
|||||||
const otherCwd = join(tempDir, "other-workspace");
|
const otherCwd = join(tempDir, "other-workspace");
|
||||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
|
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
|
||||||
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
|
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
|
||||||
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
|
const gateway = createPiSessionManagerGateway(piProfileOptions());
|
||||||
|
|
||||||
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
||||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
|
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
|
||||||
@@ -76,18 +99,33 @@ describe("Pi session manager gateway", () => {
|
|||||||
const envSessionDir = join(tempDir, "env-sessions");
|
const envSessionDir = join(tempDir, "env-sessions");
|
||||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
|
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
|
||||||
await writeSessionFile(envSessionDir, "env-session", cwd);
|
await writeSessionFile(envSessionDir, "env-session", cwd);
|
||||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
|
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envSessionDir }));
|
||||||
|
|
||||||
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
||||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
|
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes generic env session directories in global listing", async () => {
|
||||||
|
for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]) {
|
||||||
|
const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`);
|
||||||
|
await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd);
|
||||||
|
const gateway = createPiSessionManagerGateway({
|
||||||
|
agentDir,
|
||||||
|
env: { [envKey]: envSessionDir },
|
||||||
|
sessionDirEnvKeys: [envKey],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
||||||
|
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: `${envKey.toLowerCase()}-session`, cwd })]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
|
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
|
||||||
const sharedSessionDir = join(tempDir, "shared-sessions");
|
const sharedSessionDir = join(tempDir, "shared-sessions");
|
||||||
const otherCwd = join(tempDir, "other-workspace");
|
const otherCwd = join(tempDir, "other-workspace");
|
||||||
await writeSessionFile(sharedSessionDir, "session-a", cwd);
|
await writeSessionFile(sharedSessionDir, "session-a", cwd);
|
||||||
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
|
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
|
||||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } });
|
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: sharedSessionDir }));
|
||||||
|
|
||||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
|
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
|
||||||
const created = gateway.create(cwd);
|
const created = gateway.create(cwd);
|
||||||
@@ -101,7 +139,7 @@ describe("Pi session manager gateway", () => {
|
|||||||
// hiding every session outside the daemon's own launch directory.
|
// hiding every session outside the daemon's own launch directory.
|
||||||
expect(cwd).not.toBe(process.cwd());
|
expect(cwd).not.toBe(process.cwd());
|
||||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
|
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
|
||||||
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
|
const gateway = createPiSessionManagerGateway(piProfileOptions());
|
||||||
|
|
||||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
|
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
|
||||||
});
|
});
|
||||||
@@ -129,12 +167,16 @@ describe("session listing canonicalization", () => {
|
|||||||
// Headers are written by the Pi CLI / SDK consumers and may contain
|
// Headers are written by the Pi CLI / SDK consumers and may contain
|
||||||
// unnormalized paths (trailing separators, redundant segments).
|
// unnormalized paths (trailing separators, redundant segments).
|
||||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
|
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
|
||||||
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
|
const gateway = createPiSessionManagerGateway(piProfileOptions());
|
||||||
|
|
||||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
|
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function piProfileOptions(env: NodeJS.ProcessEnv = {}) {
|
||||||
|
return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() };
|
||||||
|
}
|
||||||
|
|
||||||
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
|
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
|
||||||
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
|
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import type { Dirent } from "node:fs";
|
|||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||||
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
|
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
|
||||||
|
|
||||||
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
|
||||||
|
|
||||||
type SessionDirSource = "env" | "settings" | "pi-default";
|
type SessionDirSource = "env" | "settings" | "pi-default";
|
||||||
|
|
||||||
export interface SessionDirResolution {
|
export interface SessionDirResolution {
|
||||||
@@ -17,17 +15,23 @@ export interface SessionDirResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDirResolverOptions {
|
export interface SessionDirResolverOptions {
|
||||||
agentDir?: string;
|
agentDir: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env: Readonly<NodeJS.ProcessEnv>;
|
||||||
|
sessionDirEnvKeys: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionDirResolver {
|
export class SessionDirResolver {
|
||||||
private readonly agentDir: string;
|
private readonly agentDir: string;
|
||||||
private readonly env: NodeJS.ProcessEnv;
|
private readonly envSessionDir: string | undefined;
|
||||||
|
private readonly homeDir: string;
|
||||||
|
|
||||||
constructor(options: SessionDirResolverOptions = {}) {
|
constructor(options: SessionDirResolverOptions) {
|
||||||
this.agentDir = options.agentDir ?? getAgentDir();
|
this.agentDir = options.agentDir;
|
||||||
this.env = options.env ?? process.env;
|
this.envSessionDir = options.sessionDirEnvKeys
|
||||||
|
.map((key) => options.env[key])
|
||||||
|
.find((value) => value !== undefined && value !== "");
|
||||||
|
const configuredHome = options.env["HOME"];
|
||||||
|
this.homeDir = configuredHome !== undefined && configuredHome !== "" && isAbsolute(configuredHome) ? configuredHome : homedir();
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultSessionsRoot(): string {
|
defaultSessionsRoot(): string {
|
||||||
@@ -35,21 +39,19 @@ export class SessionDirResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
globalEnvSessionDir(): string | undefined {
|
globalEnvSessionDir(): string | undefined {
|
||||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
if (this.envSessionDir === undefined) return undefined;
|
||||||
if (envSessionDir === undefined || envSessionDir === "") return undefined;
|
const expanded = expandTildePath(this.envSessionDir, this.homeDir);
|
||||||
const expanded = expandTildePath(envSessionDir);
|
|
||||||
return isAbsolute(expanded) ? expanded : undefined;
|
return isAbsolute(expanded) ? expanded : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(cwd: string): SessionDirResolution {
|
resolve(cwd: string): SessionDirResolution {
|
||||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
if (this.envSessionDir !== undefined) {
|
||||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
|
||||||
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
|
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
|
||||||
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
|
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
|
||||||
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true };
|
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
|
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
|
||||||
@@ -58,7 +60,7 @@ export class SessionDirResolver {
|
|||||||
|
|
||||||
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
|
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
|
||||||
|
|
||||||
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway {
|
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway {
|
||||||
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
|
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise<PiSessionLi
|
|||||||
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
|
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> {
|
export async function listSessionsInDefaultPiStore(storeRoot: string): Promise<PiSessionListEntry[]> {
|
||||||
let entries: Dirent[];
|
let entries: Dirent[];
|
||||||
try {
|
try {
|
||||||
entries = await readdir(storeRoot, { withFileTypes: true });
|
entries = await readdir(storeRoot, { withFileTypes: true });
|
||||||
@@ -124,11 +126,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio
|
|||||||
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
export function defaultPiSessionsRoot(agentDir: string): string {
|
||||||
return join(agentDir, "sessions");
|
return join(agentDir, "sessions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultPiSessionDir(cwd: string, agentDir = getAgentDir()): string {
|
export function defaultPiSessionDir(cwd: string, agentDir: string): string {
|
||||||
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
|
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri
|
|||||||
return join(storeRoot, safePath);
|
return join(storeRoot, safePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfiguredPath(path: string, cwd: string): string {
|
export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string {
|
||||||
const expanded = expandTildePath(path);
|
const expanded = expandTildePath(path, homeDir);
|
||||||
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandTildePath(path: string): string {
|
function expandTildePath(path: string, homeDir: string): string {
|
||||||
if (path === "~") return homedir();
|
if (path === "~") return homeDir;
|
||||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
if (path.startsWith("~/")) return join(homeDir, path.slice(2));
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { PiSessionService } from "./piSessionService.js";
|
import { PiSessionService } from "./piSessionService.js";
|
||||||
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
describe("PiSessionService archive and cleanup", () => {
|
describe("PiSessionService archive and cleanup", () => {
|
||||||
it("archives a session subtree within the root workspace", async () => {
|
it("archives a session subtree within the root workspace", async () => {
|
||||||
const archivedInputs: string[] = [];
|
const archivedInputs: string[] = [];
|
||||||
@@ -12,6 +14,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
|
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
|
||||||
const fake = fakeRuntime("root", { sessionFile: root.path });
|
const fake = fakeRuntime("root", { sessionFile: root.path });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
|
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
|
||||||
@@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
it("permanently deletes archived sessions through the archive store", async () => {
|
it("permanently deletes archived sessions through the archive store", async () => {
|
||||||
const deletedSessionIds: string[] = [];
|
const deletedSessionIds: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||||
@@ -78,6 +82,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
|
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
|
||||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: () => Promise.resolve(undefined),
|
get: () => Promise.resolve(undefined),
|
||||||
@@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
let createCalls = 0;
|
let createCalls = 0;
|
||||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
createCalls += 1;
|
createCalls += 1;
|
||||||
return Promise.resolve(busy.runtime);
|
return Promise.resolve(busy.runtime);
|
||||||
@@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const busy = fakeRuntime("busy-archived", { isStreaming: true });
|
const busy = fakeRuntime("busy-archived", { isStreaming: true });
|
||||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||||
@@ -188,6 +195,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||||
const listCalls: string[] = [];
|
const listCalls: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([
|
list: () => Promise.resolve([
|
||||||
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||||
@@ -230,6 +238,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
|
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
|
||||||
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
|
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([archived, otherArchived]),
|
list: () => Promise.resolve([archived, otherArchived]),
|
||||||
@@ -282,6 +291,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
||||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([
|
list: () => Promise.resolve([
|
||||||
@@ -323,6 +333,7 @@ describe("PiSessionService archive and cleanup", () => {
|
|||||||
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
|
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
|
||||||
const archivedInputs: string[] = [];
|
const archivedInputs: string[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
|
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
|
||||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
function deferred<T = void>() {
|
function deferred<T = void>() {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
@@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime();
|
const fake = fakeRuntime();
|
||||||
let createCalls = 0;
|
let createCalls = 0;
|
||||||
const createAgentRuntime: RuntimeCreator = async () => {
|
let runtimeAgentDir: string | undefined;
|
||||||
|
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||||
createCalls += 1;
|
createCalls += 1;
|
||||||
|
runtimeAgentDir = options.agentDir;
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
return fake.runtime;
|
return fake.runtime;
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const session = await service.start("/workspace");
|
const session = await service.start("/workspace");
|
||||||
|
|
||||||
expect(createCalls).toBe(1);
|
expect(createCalls).toBe(1);
|
||||||
|
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
|
||||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||||
expect(service.activeCount()).toBe(1);
|
expect(service.activeCount()).toBe(1);
|
||||||
@@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
let service: PiSessionService | undefined;
|
let service: PiSessionService | undefined;
|
||||||
try {
|
try {
|
||||||
service = new PiSessionService(hub, {
|
service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const fake = fakeRuntime("legacy-session");
|
const fake = fakeRuntime("legacy-session");
|
||||||
const open = vi.fn(() => fakeSessionManager());
|
const open = vi.fn(() => fakeSessionManager());
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => fakeSessionManager(),
|
create: () => fakeSessionManager(),
|
||||||
@@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const gateway = sessionGateway([sessionRecord(sessionId)]);
|
const gateway = sessionGateway([sessionRecord(sessionId)]);
|
||||||
const open = vi.spyOn(gateway, "open");
|
const open = vi.spyOn(gateway, "open");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: gateway,
|
sessionManager: gateway,
|
||||||
@@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
: Promise.resolve(runtime);
|
: Promise.resolve(runtime);
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
||||||
@@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const runtimeResult = deferred<PiSessionRuntime>();
|
const runtimeResult = deferred<PiSessionRuntime>();
|
||||||
const fake = fakeRuntime(sessionId);
|
const fake = fakeRuntime(sessionId);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
createStarted.resolve();
|
createStarted.resolve();
|
||||||
@@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
||||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
service = new PiSessionService(hub, {
|
service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("idle-session")]),
|
sessionManager: sessionGateway([sessionRecord("idle-session")]),
|
||||||
heartbeatIntervalMs: 1_000,
|
heartbeatIntervalMs: 1_000,
|
||||||
@@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("completion-session")]),
|
sessionManager: sessionGateway([sessionRecord("completion-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
|
|
||||||
it("uses injected archive and session-manager gateways for listing", async () => {
|
it("uses injected archive and session-manager gateways for listing", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
|
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
|
||||||
get: () => Promise.resolve(undefined),
|
get: () => Promise.resolve(undefined),
|
||||||
@@ -394,6 +410,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
|
|
||||||
it("lists archived records that have been moved out of the active session directory", async () => {
|
it("lists archived records that have been moved out of the active session directory", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
||||||
get: () => Promise.resolve(undefined),
|
get: () => Promise.resolve(undefined),
|
||||||
@@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("runtime-reload-session");
|
const fake = fakeRuntime("runtime-reload-session");
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
return runtime;
|
return runtime;
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
sessionManager: sessionGateway([sessionRecord("reload-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -479,6 +498,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
it("refuses to reload a session that has active work in progress", async () => {
|
it("refuses to reload a session that has active work in progress", async () => {
|
||||||
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
const fake = fakeRuntime("busy-session", { isStreaming: true });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
sessionManager: sessionGateway([sessionRecord("busy-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
|
|
||||||
it("refuses to reload an archived session", async () => {
|
it("refuses to reload an archived session", async () => {
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([]),
|
list: () => Promise.resolve([]),
|
||||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||||
@@ -514,6 +535,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
|||||||
it("reconciles workspace activity when listing only archived sessions", async () => {
|
it("reconciles workspace activity when listing only archived sessions", async () => {
|
||||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
archiveStore: {
|
archiveStore: {
|
||||||
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
|
||||||
get: () => Promise.resolve(undefined),
|
get: () => Promise.resolve(undefined),
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { PiSessionService } from "./piSessionService.js";
|
import { PiSessionService } from "./piSessionService.js";
|
||||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
describe("PiSessionService prompt, queue, and auth warnings", () => {
|
describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||||
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
|
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
|
||||||
const fake = fakeRuntime("prompt-session");
|
const fake = fakeRuntime("prompt-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
});
|
});
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
return fake.runtime;
|
return fake.runtime;
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
getFollowUpMessages: () => ["then do this"],
|
getFollowUpMessages: () => ["then do this"],
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
getFollowUpMessages: () => ["already queued"],
|
getFollowUpMessages: () => ["already queued"],
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -234,6 +244,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
});
|
});
|
||||||
fake.session.clearQueue = clearRuntimeQueue;
|
fake.session.clearQueue = clearRuntimeQueue;
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -273,6 +284,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
it("clears an already-empty queue idempotently", async () => {
|
it("clears an already-empty queue idempotently", async () => {
|
||||||
const fake = fakeRuntime("clear-empty-queue-session");
|
const fake = fakeRuntime("clear-empty-queue-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -291,6 +303,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
it("clears queued messages when aborting active work", async () => {
|
it("clears queued messages when aborting active work", async () => {
|
||||||
const fake = fakeRuntime("abort-session");
|
const fake = fakeRuntime("abort-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -307,6 +320,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
it("clears prompts queued during compaction when aborting active work", async () => {
|
it("clears prompts queued during compaction when aborting active work", async () => {
|
||||||
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
@@ -331,6 +345,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
const fake = fakeRuntime("auth-session", { model, modelRegistry });
|
const fake = fakeRuntime("auth-session", { model, modelRegistry });
|
||||||
|
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
||||||
@@ -361,6 +376,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
it("clears queued messages when stopping a session runtime", async () => {
|
it("clears queued messages when stopping a session runtime", async () => {
|
||||||
const fake = fakeRuntime("stop-session");
|
const fake = fakeRuntime("stop-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
|||||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
describe("PiSessionService", () => {
|
describe("PiSessionService", () => {
|
||||||
describe("spawnSession", () => {
|
describe("spawnSession", () => {
|
||||||
function spawnService(decision: SpawnTargetDecision) {
|
function spawnService(decision: SpawnTargetDecision) {
|
||||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||||
const log: { details: Record<string, unknown>; message: string }[] = [];
|
const log: { details: Record<string, unknown>; message: string }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||||
@@ -41,6 +44,7 @@ describe("PiSessionService", () => {
|
|||||||
return fake.runtime;
|
return fake.runtime;
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||||
@@ -75,6 +79,7 @@ describe("PiSessionService", () => {
|
|||||||
it("is disabled when no spawn target resolver is configured", async () => {
|
it("is disabled when no spawn target resolver is configured", async () => {
|
||||||
const fake = fakeRuntime("spawned-x");
|
const fake = fakeRuntime("spawned-x");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
|||||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
describe("PiSessionService", () => {
|
describe("PiSessionService", () => {
|
||||||
describe("spawnSubsession", () => {
|
describe("spawnSubsession", () => {
|
||||||
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
|
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
|
||||||
@@ -37,6 +39,7 @@ describe("PiSessionService", () => {
|
|||||||
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
|
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
archiveStore,
|
archiveStore,
|
||||||
@@ -77,6 +80,7 @@ describe("PiSessionService", () => {
|
|||||||
return runtime;
|
return runtime;
|
||||||
};
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -116,6 +120,7 @@ describe("PiSessionService", () => {
|
|||||||
const runtimes = [parent.runtime, child.runtime];
|
const runtimes = [parent.runtime, child.runtime];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? child.runtime;
|
const runtime = runtimes[index] ?? child.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -167,6 +172,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const open = vi.fn(() => childManager);
|
const open = vi.fn(() => childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? child.runtime;
|
const runtime = runtimes[index] ?? child.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -208,6 +214,7 @@ describe("PiSessionService", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -232,6 +239,7 @@ describe("PiSessionService", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -253,6 +261,7 @@ describe("PiSessionService", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -273,6 +282,7 @@ describe("PiSessionService", () => {
|
|||||||
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
|
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(parent.runtime),
|
createAgentRuntime: runtimeCreator(parent.runtime),
|
||||||
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -293,6 +303,7 @@ describe("PiSessionService", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
createAgentRuntime: runtimeCreator(forkedParent.runtime),
|
||||||
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
|
||||||
archiveStore: emptyArchiveStore(),
|
archiveStore: emptyArchiveStore(),
|
||||||
@@ -331,6 +342,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: (_createRuntime, options) => {
|
createAgentRuntime: (_createRuntime, options) => {
|
||||||
delegationCapabilities.push(options.delegationToolsEnabled);
|
delegationCapabilities.push(options.delegationToolsEnabled);
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
@@ -393,6 +405,7 @@ describe("PiSessionService", () => {
|
|||||||
return childManager;
|
return childManager;
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -450,6 +463,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -514,6 +528,7 @@ describe("PiSessionService", () => {
|
|||||||
throw new Error(`unexpected open path ${path}`);
|
throw new Error(`unexpected open path ${path}`);
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => parentManager,
|
create: () => parentManager,
|
||||||
@@ -589,6 +604,7 @@ describe("PiSessionService", () => {
|
|||||||
throw new Error(`unexpected open path ${path}`);
|
throw new Error(`unexpected open path ${path}`);
|
||||||
});
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime,
|
createAgentRuntime,
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => copiedParentManager,
|
create: () => copiedParentManager,
|
||||||
@@ -646,6 +662,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -698,6 +715,7 @@ describe("PiSessionService", () => {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: () => {
|
createAgentRuntime: () => {
|
||||||
const runtime = runtimes[index] ?? parent.runtime;
|
const runtime = runtimes[index] ?? parent.runtime;
|
||||||
index += 1;
|
index += 1;
|
||||||
@@ -738,6 +756,7 @@ describe("PiSessionService", () => {
|
|||||||
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
|
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
|
||||||
const open = vi.fn(() => childManager);
|
const open = vi.fn(() => childManager);
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(child.runtime),
|
createAgentRuntime: runtimeCreator(child.runtime),
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => childManager,
|
create: () => childManager,
|
||||||
@@ -882,6 +901,7 @@ describe("PiSessionService", () => {
|
|||||||
it("is disabled when no spawn target resolver is configured", async () => {
|
it("is disabled when no spawn target resolver is configured", async () => {
|
||||||
const fake = fakeRuntime("nope");
|
const fake = fakeRuntime("nope");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
agentDir: TEST_AGENT_DIR,
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
sessionManager: sessionGateway([]),
|
sessionManager: sessionGateway([]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import {
|
|||||||
createAgentSessionServices,
|
createAgentSessionServices,
|
||||||
createEditToolDefinition,
|
createEditToolDefinition,
|
||||||
defineTool,
|
defineTool,
|
||||||
getAgentDir,
|
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type EditToolDetails,
|
type EditToolDetails,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||||
@@ -23,13 +22,13 @@ import { SessionCommandService } from "./sessionCommandService.js";
|
|||||||
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
|
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||||
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
||||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||||
import type { AuthChange } from "./authService.js";
|
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
|
||||||
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
|
||||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
||||||
|
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
|
||||||
|
|
||||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||||
@@ -132,9 +131,8 @@ type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "arch
|
|||||||
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
|
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PiSessionRef = ClientSessionRef;
|
export type PiSessionRef = SessionRouteRef;
|
||||||
|
type PiSessionLookup = SessionRouteLookup;
|
||||||
type PiSessionLookup = string | PiSessionRef;
|
|
||||||
|
|
||||||
export interface PiSessionListEntry {
|
export interface PiSessionListEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PiSessionServiceDependencies {
|
export interface PiSessionServiceDependencies {
|
||||||
|
agentDir: string;
|
||||||
|
sessionManager: PiSessionManagerGateway;
|
||||||
archiveStore?: SessionArchiveRepository;
|
archiveStore?: SessionArchiveRepository;
|
||||||
agentDir?: string;
|
|
||||||
sessionManager?: PiSessionManagerGateway;
|
|
||||||
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
||||||
createAgentRuntime?: CreateAgentRuntime;
|
createAgentRuntime?: CreateAgentRuntime;
|
||||||
modelRegistry?: ModelRegistryInstance;
|
modelRegistry?: ModelRegistryInstance;
|
||||||
@@ -407,7 +405,7 @@ export interface PiSessionServiceDependencies {
|
|||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PiSessionService {
|
export class PiSessionService implements SessionRouteService {
|
||||||
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
|
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
|
||||||
private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>();
|
private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>();
|
||||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||||
@@ -441,11 +439,11 @@ export class PiSessionService {
|
|||||||
private readonly logger: PiSessionLogger;
|
private readonly logger: PiSessionLogger;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
|
|
||||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
|
||||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||||
this.agentDir = deps.agentDir ?? getAgentDir();
|
this.agentDir = deps.agentDir;
|
||||||
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
|
this.sessionManager = deps.sessionManager;
|
||||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
|
||||||
this.spawnTargets = deps.spawnTargets;
|
this.spawnTargets = deps.spawnTargets;
|
||||||
this.logger = deps.logger ?? noopLogger;
|
this.logger = deps.logger ?? noopLogger;
|
||||||
this.now = deps.now ?? (() => new Date());
|
this.now = deps.now ?? (() => new Date());
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import fastifyWebsocket from "@fastify/websocket";
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
|
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
|
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||||
|
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let service: PiSessionService;
|
let service: PiSessionService;
|
||||||
let sessionManager: RejectingSessionManager;
|
let sessionManager: RejectingSessionManager;
|
||||||
@@ -17,7 +20,7 @@ beforeEach(async () => {
|
|||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
sessionManager = new RejectingSessionManager();
|
sessionManager = new RejectingSessionManager();
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
|
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
|
||||||
registerSessionRoutes(app, service, eventHub);
|
registerSessionRoutes(app, service, eventHub);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +62,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
|
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
|
||||||
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
|
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
|
||||||
routeService.messagesResponse = { messages: [message], start: 0, total: 1 };
|
routeService.messagesResponse = { messages: [message], start: 0, total: 1 };
|
||||||
@@ -85,7 +88,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||||
@@ -107,7 +110,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -130,7 +133,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +153,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
routeService.reloadError = new Error("Stop current session activity before reloading");
|
routeService.reloadError = new Error("Stop current session activity before reloading");
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
@@ -169,7 +172,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -198,7 +201,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
|
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
@@ -218,7 +221,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -239,7 +242,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -258,7 +261,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -282,7 +285,7 @@ describe("session routes", () => {
|
|||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
const eventHub = new SessionEventHub();
|
const eventHub = new SessionEventHub();
|
||||||
const routeService = new CapturingRouteSessionService(eventHub);
|
const routeService = new CapturingRouteSessionService();
|
||||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -298,10 +301,10 @@ describe("session routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class CapturingRouteSessionService extends PiSessionService {
|
class CapturingRouteSessionService implements SessionRouteService {
|
||||||
readonly calls: unknown[] = [];
|
readonly calls: unknown[] = [];
|
||||||
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
readonly reloadCalls: SessionRouteLookup[] = [];
|
||||||
readonly clearQueueCalls: (string | PiSessionRef)[] = [];
|
readonly clearQueueCalls: SessionRouteLookup[] = [];
|
||||||
messagesResponse: unknown[] | MessagePage = [];
|
messagesResponse: unknown[] | MessagePage = [];
|
||||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||||
@@ -310,37 +313,40 @@ class CapturingRouteSessionService extends PiSessionService {
|
|||||||
reloadError: Error | undefined;
|
reloadError: Error | undefined;
|
||||||
clearQueueError: Error | undefined;
|
clearQueueError: Error | undefined;
|
||||||
|
|
||||||
constructor(eventHub: SessionEventHub) {
|
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
||||||
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
|
||||||
this.cleanupPreviewCalls.push(request);
|
this.cleanupPreviewCalls.push(request);
|
||||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
override cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
|
cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
|
||||||
this.cleanupCalls.push(request);
|
this.cleanupCalls.push(request);
|
||||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
|
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
override archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
||||||
this.bulkArchiveCalls.push([...refs]);
|
this.bulkArchiveCalls.push([...refs]);
|
||||||
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
||||||
}
|
}
|
||||||
|
|
||||||
override deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
|
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
|
||||||
this.bulkDeleteCalls.push([...refs]);
|
this.bulkDeleteCalls.push([...refs]);
|
||||||
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
||||||
}
|
}
|
||||||
|
|
||||||
override reload(lookup: string | PiSessionRef): Promise<void> {
|
reload(lookup: SessionRouteLookup): Promise<void> {
|
||||||
this.reloadCalls.push(lookup);
|
this.reloadCalls.push(lookup);
|
||||||
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
|
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
override clearQueue(lookup: string | PiSessionRef): Promise<SessionStatus> {
|
dispose(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): never { throw unusedRouteMethod("list"); }
|
||||||
|
start(): never { throw unusedRouteMethod("start"); }
|
||||||
|
|
||||||
|
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
|
||||||
this.clearQueueCalls.push(lookup);
|
this.clearQueueCalls.push(lookup);
|
||||||
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
|
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -355,11 +361,11 @@ class CapturingRouteSessionService extends PiSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override messages(): Promise<unknown[] | MessagePage> {
|
messages(): Promise<unknown[] | MessagePage> {
|
||||||
return Promise.resolve(this.messagesResponse);
|
return Promise.resolve(this.messagesResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
override status(lookup: string | PiSessionRef) {
|
status(lookup: SessionRouteLookup) {
|
||||||
this.calls.push(lookup);
|
this.calls.push(lookup);
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
sessionId: sessionIdFromLookup(lookup),
|
sessionId: sessionIdFromLookup(lookup),
|
||||||
@@ -373,12 +379,20 @@ class CapturingRouteSessionService extends PiSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override prompt(lookup: string | PiSessionRef, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
|
availableModels(): Promise<[]> { return Promise.resolve([]); }
|
||||||
|
setModel(): never { throw unusedRouteMethod("setModel"); }
|
||||||
|
cycleModel(): never { throw unusedRouteMethod("cycleModel"); }
|
||||||
|
availableThinkingLevels(): Promise<[]> { return Promise.resolve([]); }
|
||||||
|
setThinkingLevel(): never { throw unusedRouteMethod("setThinkingLevel"); }
|
||||||
|
cycleThinkingLevel(): never { throw unusedRouteMethod("cycleThinkingLevel"); }
|
||||||
|
commands(): Promise<[]> { return Promise.resolve([]); }
|
||||||
|
|
||||||
|
prompt(lookup: SessionRouteLookup, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
|
||||||
this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments });
|
this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) {
|
saveAttachments(_lookup: SessionRouteLookup, attachments: unknown, folder?: string) {
|
||||||
const list = Array.isArray(attachments) ? attachments : [];
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
|
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
|
||||||
path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`,
|
path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`,
|
||||||
@@ -386,6 +400,19 @@ class CapturingRouteSessionService extends PiSessionService {
|
|||||||
size: Buffer.from(attachment.data, "base64").byteLength,
|
size: Buffer.from(attachment.data, "base64").byteLength,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shell(): never { throw unusedRouteMethod("shell"); }
|
||||||
|
runCommand(): never { throw unusedRouteMethod("runCommand"); }
|
||||||
|
respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); }
|
||||||
|
abort(): never { throw unusedRouteMethod("abort"); }
|
||||||
|
stop(): never { throw unusedRouteMethod("stop"); }
|
||||||
|
archive(): never { throw unusedRouteMethod("archive"); }
|
||||||
|
archiveTree(): never { throw unusedRouteMethod("archiveTree"); }
|
||||||
|
restore(): never { throw unusedRouteMethod("restore"); }
|
||||||
|
deleteArchived(): never { throw unusedRouteMethod("deleteArchived"); }
|
||||||
|
|
||||||
|
|
||||||
|
detachParent(): never { throw unusedRouteMethod("detachParent"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
class RejectingSessionManager implements PiSessionManagerGateway {
|
class RejectingSessionManager implements PiSessionManagerGateway {
|
||||||
@@ -412,6 +439,10 @@ class RejectingSessionManager implements PiSessionManagerGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionIdFromLookup(lookup: string | PiSessionRef): string {
|
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
|
||||||
return typeof lookup === "string" ? lookup : lookup.id;
|
return typeof lookup === "string" ? lookup : lookup.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unusedRouteMethod(name: string): Error {
|
||||||
|
return new Error(`Route test did not expect ${name} to be called`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanup
|
|||||||
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
||||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
|
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||||
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
|
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
|
||||||
|
|
||||||
type SessionLookup = string | PiSessionRef;
|
type SessionLookup = SessionRouteLookup;
|
||||||
|
|
||||||
interface SessionQuery {
|
interface SessionQuery {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -30,7 +30,7 @@ interface AttachmentsRequestBody {
|
|||||||
folder?: unknown;
|
folder?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void {
|
||||||
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
||||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||||
try {
|
try {
|
||||||
@@ -230,9 +230,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
|
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
await sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
||||||
return { stopped: true };
|
return { stopped: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
SavedPromptAttachment,
|
||||||
|
SessionBulkArchiveResponse,
|
||||||
|
SessionBulkDeleteArchivedResponse,
|
||||||
|
SessionBulkMutationRef,
|
||||||
|
} from "../../shared/apiTypes.js";
|
||||||
|
import type {
|
||||||
|
ClientArchiveSessionsResponse,
|
||||||
|
ClientCommand,
|
||||||
|
ClientCommandResult,
|
||||||
|
ClientMessagePage,
|
||||||
|
ClientSession,
|
||||||
|
ClientSessionCleanupExecuteResponse,
|
||||||
|
ClientSessionCleanupPreviewResponse,
|
||||||
|
ClientSessionModel,
|
||||||
|
ClientSessionRef,
|
||||||
|
ClientSessionStatus,
|
||||||
|
ClientThinkingLevel,
|
||||||
|
} from "../types.js";
|
||||||
|
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||||
|
|
||||||
|
export type SessionRouteRef = ClientSessionRef;
|
||||||
|
export type SessionRouteLookup = string | SessionRouteRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-facing session contract for PI WEB's HTTP/WebSocket API.
|
||||||
|
*
|
||||||
|
* Keep transport concerns separate from the bundled Pi SDK implementation so
|
||||||
|
* routes remain testable. Pi-specific lifecycle hooks such as auth-change
|
||||||
|
* handling and daemon shutdown stay on the concrete service.
|
||||||
|
*/
|
||||||
|
export interface SessionRouteService {
|
||||||
|
list(cwd: string): Promise<ClientSession[]>;
|
||||||
|
start(cwd: string): Promise<ClientSession>;
|
||||||
|
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
|
||||||
|
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||||
|
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||||
|
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||||
|
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||||
|
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
|
||||||
|
availableThinkingLevels(ref: SessionRouteLookup): Promise<ClientThinkingLevel[]>;
|
||||||
|
setThinkingLevel(ref: SessionRouteLookup, level: string): Promise<ClientSessionStatus>;
|
||||||
|
cycleThinkingLevel(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||||
|
commands(ref: SessionRouteLookup): Promise<ClientCommand[]>;
|
||||||
|
prompt(ref: SessionRouteLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void>;
|
||||||
|
saveAttachments(ref: SessionRouteLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]>;
|
||||||
|
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse>;
|
||||||
|
cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse>;
|
||||||
|
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse>;
|
||||||
|
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse>;
|
||||||
|
shell(ref: SessionRouteLookup, text: string): Promise<void>;
|
||||||
|
runCommand(ref: SessionRouteLookup, text: string): Promise<ClientCommandResult>;
|
||||||
|
respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise<ClientCommandResult>;
|
||||||
|
abort(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
stop(ref: SessionRouteLookup): void | Promise<void>;
|
||||||
|
archive(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
archiveTree(ref: SessionRouteLookup): Promise<ClientArchiveSessionsResponse>;
|
||||||
|
restore(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
deleteArchived(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
reload(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
detachParent(ref: SessionRouteLookup): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { EffectivePiWebAgentConfig } from "../config.js";
|
||||||
|
import { createActiveAgentProfileDescriptor } from "./activeAgentProfile.js";
|
||||||
|
|
||||||
|
const baseAgent: EffectivePiWebAgentConfig = {
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: "/opt/acme-agent/state",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("active agent profile descriptor", () => {
|
||||||
|
it("builds a stable revision from every effective profile field", () => {
|
||||||
|
const first = createActiveAgentProfileDescriptor(baseAgent);
|
||||||
|
const second = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: [...baseAgent.sessionDirEnvKeys] });
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u);
|
||||||
|
expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision);
|
||||||
|
expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision);
|
||||||
|
expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }).revision).not.toBe(first.revision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an immutable snapshot for the session daemon profile epoch", () => {
|
||||||
|
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
|
||||||
|
const profile = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys });
|
||||||
|
sessionDirEnvKeys.push("LATE_MUTATION");
|
||||||
|
|
||||||
|
expect(Object.isFrozen(profile)).toBe(true);
|
||||||
|
expect(Object.isFrozen(profile.sessionDirEnvKeys)).toBe(true);
|
||||||
|
expect(profile.sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
|
||||||
|
expect(Reflect.set(profile, "command", "mutated-agent")).toBe(false);
|
||||||
|
expect(Reflect.set(profile.sessionDirEnvKeys, "0", "MUTATED_SESSION_DIR")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects profile fields outside the host and explicit environment policy", () => {
|
||||||
|
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, command: "./acme-agent" })).toThrow("must be valid for this host");
|
||||||
|
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, dir: "relative/state" })).toThrow("must be valid for this host");
|
||||||
|
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] })).toThrow("explicit PI WEB policy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies only the secret-free descriptor fields", () => {
|
||||||
|
const input = {
|
||||||
|
...baseAgent,
|
||||||
|
token: "must-not-cross-the-protocol",
|
||||||
|
auth: { apiKey: "also-secret" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const profile = createActiveAgentProfileDescriptor(input);
|
||||||
|
|
||||||
|
expect(profile).toEqual({
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: profile.revision,
|
||||||
|
command: baseAgent.command,
|
||||||
|
dir: baseAgent.dir,
|
||||||
|
sessionDirEnvKeys: baseAgent.sessionDirEnvKeys,
|
||||||
|
});
|
||||||
|
expect(profile.revision).toMatch(/^sha256:[0-9a-f]{64}$/u);
|
||||||
|
expect(JSON.stringify(profile)).not.toContain("secret");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost, PI_CODING_AGENT_SESSION_DIR_ENV, PI_WEB_AGENT_SESSION_DIR_ENV, type EffectivePiWebAgentConfig } from "../config.js";
|
||||||
|
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
|
||||||
|
import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js";
|
||||||
|
|
||||||
|
export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor {
|
||||||
|
if (!isSafeAgentCommandForHost(agent.command) || !isHostAbsoluteAgentDir(agent.dir)) {
|
||||||
|
throw new Error("Active agent profile command and directory must be valid for this host");
|
||||||
|
}
|
||||||
|
if (!hasValidSessionDirEnvKeys(agent.sessionDirEnvKeys)) {
|
||||||
|
throw new Error("Active agent profile session directory environment keys must use the explicit PI WEB policy");
|
||||||
|
}
|
||||||
|
const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]);
|
||||||
|
const revisionInput = JSON.stringify({
|
||||||
|
schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION,
|
||||||
|
command: agent.command,
|
||||||
|
dir: agent.dir,
|
||||||
|
sessionDirEnvKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION,
|
||||||
|
revision: `sha256:${createHash("sha256").update(revisionInput).digest("hex")}`,
|
||||||
|
command: agent.command,
|
||||||
|
dir: agent.dir,
|
||||||
|
sessionDirEnvKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidSessionDirEnvKeys(keys: readonly string[]): boolean {
|
||||||
|
return (keys.length === 1 || keys.length === 2)
|
||||||
|
&& keys[0] === PI_WEB_AGENT_SESSION_DIR_ENV
|
||||||
|
&& (keys.length === 1 || keys[1] === PI_CODING_AGENT_SESSION_DIR_ENV);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionDaemonClient } from "./sessionDaemonClient.js";
|
||||||
|
|
||||||
|
const activeAgentProfile = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: "/opt/acme-agent/state",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SessionDaemonClient active agent profile protocol", () => {
|
||||||
|
it("returns the validated immutable profile from the daemon runtime endpoint", async () => {
|
||||||
|
const client = new SessionDaemonClient();
|
||||||
|
const request = vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(activeAgentProfile));
|
||||||
|
|
||||||
|
const result = await client.getActiveAgentProfile();
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith("GET", "/runtime");
|
||||||
|
expect(result).toEqual({ status: "available", profile: activeAgentProfile });
|
||||||
|
if (result.status === "available") {
|
||||||
|
expect(Object.isFrozen(result.profile)).toBe(true);
|
||||||
|
expect(Object.isFrozen(result.profile.sessionDirEnvKeys)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes invalid protocol responses from daemon unavailability", async () => {
|
||||||
|
const invalidClient = new SessionDaemonClient();
|
||||||
|
vi.spyOn(invalidClient, "request").mockResolvedValue(runtimeResponse({
|
||||||
|
...activeAgentProfile,
|
||||||
|
token: "must-not-cross-the-protocol",
|
||||||
|
}));
|
||||||
|
const unavailableClient = new SessionDaemonClient();
|
||||||
|
vi.spyOn(unavailableClient, "request").mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||||
|
|
||||||
|
await expect(invalidClient.getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "invalid",
|
||||||
|
error: "session daemon runtime response was invalid",
|
||||||
|
});
|
||||||
|
await expect(unavailableClient.getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "unavailable",
|
||||||
|
error: "connect ECONNREFUSED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")("rejects foreign-platform active state paths before local consumers use them", async () => {
|
||||||
|
const client = new SessionDaemonClient();
|
||||||
|
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse({
|
||||||
|
...activeAgentProfile,
|
||||||
|
dir: "C:\\agent-profiles\\acme",
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(client.getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "invalid",
|
||||||
|
error: "session daemon active agent profile was not valid for this host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => {
|
||||||
|
const client = new SessionDaemonClient();
|
||||||
|
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined));
|
||||||
|
|
||||||
|
await expect(client.getActiveAgentProfile()).resolves.toEqual({
|
||||||
|
status: "invalid",
|
||||||
|
error: "session daemon runtime response did not include an active agent profile",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeResponse(profile: unknown) {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
...(profile === undefined ? {} : { activeAgentProfile: profile }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost } from "../config.js";
|
||||||
|
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
|
||||||
|
import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||||
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
|
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
|
||||||
|
|
||||||
|
export type SessionDaemonAgentProfileResult =
|
||||||
|
| { status: "available"; profile: ActiveAgentProfileDescriptor }
|
||||||
|
| { status: "unavailable"; error: string }
|
||||||
|
| { status: "invalid"; error: string };
|
||||||
|
|
||||||
|
export interface SessionDaemonRequestClient {
|
||||||
|
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionDaemonClient {
|
export class SessionDaemonClient {
|
||||||
private readonly baseUrl = sessiondHttpUrl();
|
private readonly baseUrl = sessiondHttpUrl();
|
||||||
private readonly socketPath = sessiondSocketPath();
|
private readonly socketPath = sessiondSocketPath();
|
||||||
@@ -12,6 +24,10 @@ export class SessionDaemonClient {
|
|||||||
return this.requestSocket(method, path, payload);
|
return this.requestSocket(method, path, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
|
||||||
|
return getSessionDaemonActiveAgentProfile(this);
|
||||||
|
}
|
||||||
|
|
||||||
connectWebSocket(path: string): WebSocket {
|
connectWebSocket(path: string): WebSocket {
|
||||||
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
||||||
const url = new URL(path, this.baseUrl);
|
const url = new URL(path, this.baseUrl);
|
||||||
@@ -66,3 +82,39 @@ export class SessionDaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRequestClient): Promise<SessionDaemonAgentProfileResult> {
|
||||||
|
let response: Awaited<ReturnType<SessionDaemonRequestClient["request"]>>;
|
||||||
|
try {
|
||||||
|
response = await client.request("GET", "/runtime");
|
||||||
|
} catch (error) {
|
||||||
|
return { status: "unavailable", error: errorMessage(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = response.body === "" ? undefined : JSON.parse(response.body);
|
||||||
|
} catch {
|
||||||
|
return { status: "invalid", error: "session daemon runtime response was not valid JSON" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = parsePiWebRuntimeComponent(value);
|
||||||
|
if (runtime?.component !== "sessiond") {
|
||||||
|
return { status: "invalid", error: "session daemon runtime response was invalid" };
|
||||||
|
}
|
||||||
|
if (runtime.activeAgentProfile === undefined) {
|
||||||
|
return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" };
|
||||||
|
}
|
||||||
|
if (!isSafeAgentCommandForHost(runtime.activeAgentProfile.command) || !isHostAbsoluteAgentDir(runtime.activeAgentProfile.dir)) {
|
||||||
|
return { status: "invalid", error: "session daemon active agent profile was not valid for this host" };
|
||||||
|
}
|
||||||
|
return { status: "available", profile: runtime.activeAgentProfile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ActiveAgentProfileDescriptor } from "./apiTypes.js";
|
||||||
|
|
||||||
|
export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
|
export function isPiCompanionCommand(command: string): boolean {
|
||||||
|
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
|
||||||
|
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesPiCodingAgentStateCompatibility(command: string): boolean {
|
||||||
|
return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_AGENT_PROFILE_FIELDS = new Set([
|
||||||
|
"schemaVersion",
|
||||||
|
"revision",
|
||||||
|
"command",
|
||||||
|
"dir",
|
||||||
|
"sessionDirEnvKeys",
|
||||||
|
]);
|
||||||
|
const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||||
|
const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u;
|
||||||
|
const ACTIVE_SESSION_DIR_ENV_KEYS = new Set(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
|
||||||
|
|
||||||
|
export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined {
|
||||||
|
if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined;
|
||||||
|
|
||||||
|
const schemaVersion = value["schemaVersion"];
|
||||||
|
const revision = value["revision"];
|
||||||
|
const command = value["command"];
|
||||||
|
const dir = value["dir"];
|
||||||
|
const sessionDirEnvKeys = value["sessionDirEnvKeys"];
|
||||||
|
if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined;
|
||||||
|
if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined;
|
||||||
|
if (typeof command !== "string" || !isPortableAgentCommand(command)) return undefined;
|
||||||
|
if (typeof dir !== "string" || !isPortableAbsolutePath(dir)) return undefined;
|
||||||
|
if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined;
|
||||||
|
if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined;
|
||||||
|
if (sessionDirEnvKeys[0] !== "PI_WEB_AGENT_SESSION_DIR" || sessionDirEnvKeys.some((key) => !ACTIVE_SESSION_DIR_ENV_KEYS.has(key))) return undefined;
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion,
|
||||||
|
revision,
|
||||||
|
command,
|
||||||
|
dir,
|
||||||
|
sessionDirEnvKeys: Object.freeze([...sessionDirEnvKeys]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortableAgentCommand(value: string): boolean {
|
||||||
|
if (value !== value.trim() || /[\s;&|`$<>]/u.test(value)) return false;
|
||||||
|
if (isPortableAbsolutePath(value)) return !value.endsWith("/") && !value.endsWith("\\");
|
||||||
|
return SAFE_BARE_AGENT_COMMAND_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortableAbsolutePath(value: string): boolean {
|
||||||
|
if (value === "" || value !== value.trim() || hasControlCharacter(value)) return false;
|
||||||
|
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||||
|
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
if (code < 32 || code === 127) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export const PI_WEB_CAPABILITIES = {
|
|||||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||||
piPackagesManage: "piPackages.manage",
|
piPackagesManage: "piPackages.manage",
|
||||||
selectedMachineSettings: "settings.selectedMachine",
|
selectedMachineSettings: "settings.selectedMachine",
|
||||||
|
agentProfileConfig: "settings.agentProfile",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||||
@@ -66,6 +67,13 @@ export interface PiWebUploadsConfig {
|
|||||||
defaultFolder?: string;
|
defaultFolder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PiWebAgentConfig {
|
||||||
|
/** Pi-compatible companion CLI used for diagnostics and safe package-managed updates. */
|
||||||
|
command?: string;
|
||||||
|
/** Pi-compatible profile directory containing auth.json, models.json, settings.json, and sessions/. */
|
||||||
|
dir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PiWebConfigValues {
|
export interface PiWebConfigValues {
|
||||||
host?: string;
|
host?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -87,6 +95,8 @@ export interface PiWebConfigValues {
|
|||||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||||
*/
|
*/
|
||||||
subsessions?: boolean;
|
subsessions?: boolean;
|
||||||
|
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
|
||||||
|
agent?: PiWebAgentConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
|
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
|
||||||
@@ -141,12 +151,19 @@ export interface PiPackageMutationResponse extends PiPackagesResponse {
|
|||||||
removed?: boolean;
|
removed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PiWebAgentDirEnvSource = "pi-web" | "pi-compatibility";
|
||||||
|
|
||||||
export interface PiWebConfigEnvOverrides {
|
export interface PiWebConfigEnvOverrides {
|
||||||
host: boolean;
|
host: boolean;
|
||||||
port: boolean;
|
port: boolean;
|
||||||
allowedHosts: boolean;
|
allowedHosts: boolean;
|
||||||
spawnSessions: boolean;
|
spawnSessions: boolean;
|
||||||
subsessions: boolean;
|
subsessions: boolean;
|
||||||
|
agentCommand: boolean;
|
||||||
|
agentDir: boolean;
|
||||||
|
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
|
||||||
|
agentDirSource?: PiWebAgentDirEnvSource;
|
||||||
|
agentSessionDir: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PiWebConfigResponse {
|
export interface PiWebConfigResponse {
|
||||||
@@ -582,12 +599,23 @@ export interface PiWebComponentStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Secret-free identity of the Pi-compatible CLI/state profile fixed for one sessiond lifetime. */
|
||||||
|
export interface ActiveAgentProfileDescriptor {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly revision: string;
|
||||||
|
readonly command: string;
|
||||||
|
readonly dir: string;
|
||||||
|
readonly sessionDirEnvKeys: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PiWebRuntimeComponent {
|
export interface PiWebRuntimeComponent {
|
||||||
component: PiWebServiceComponent;
|
component: PiWebServiceComponent;
|
||||||
label: string;
|
label: string;
|
||||||
runtimeVersion?: string;
|
runtimeVersion?: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
capabilities: PiWebCapability[];
|
capabilities: PiWebCapability[];
|
||||||
|
/** Present only for a session daemon that supports active-profile reporting. */
|
||||||
|
activeAgentProfile?: ActiveAgentProfileDescriptor;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ describe("PI WEB capabilities", () => {
|
|||||||
it("advertises web-only capabilities without requiring session daemon support", () => {
|
it("advertises web-only capabilities without requiring session daemon support", () => {
|
||||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||||
|
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
|
||||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||||
|
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
|
||||||
|
|
||||||
expect(effectivePiWebCapabilities({
|
expect(effectivePiWebCapabilities({
|
||||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
|
||||||
sessiond: { available: false, capabilities: [] },
|
sessiond: { available: false, capabilities: [] },
|
||||||
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires web and session daemon support for authoritative session persistence", () => {
|
it("requires web and session daemon support for authoritative session persistence", () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
|||||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||||
PI_WEB_CAPABILITIES.piPackagesManage,
|
PI_WEB_CAPABILITIES.piPackagesManage,
|
||||||
PI_WEB_CAPABILITIES.selectedMachineSettings,
|
PI_WEB_CAPABILITIES.selectedMachineSettings,
|
||||||
|
PI_WEB_CAPABILITIES.agentProfileConfig,
|
||||||
] as const satisfies readonly PiWebCapability[];
|
] as const satisfies readonly PiWebCapability[];
|
||||||
|
|
||||||
export const SESSIOND_RUNTIME_CAPABILITIES = [
|
export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||||
@@ -40,6 +41,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
|||||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||||
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
||||||
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
|
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
|
||||||
|
[PI_WEB_CAPABILITIES.agentProfileConfig]: ["web"],
|
||||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||||
|
|
||||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ describe("PI WEB status parsing", () => {
|
|||||||
packageName: "@jmfederico/pi-web",
|
packageName: "@jmfederico/pi-web",
|
||||||
generatedAt: "now",
|
generatedAt: "now",
|
||||||
components: {
|
components: {
|
||||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
|
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
|
||||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
|
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
|
||||||
},
|
},
|
||||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
|
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
components: {
|
components: {
|
||||||
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
|
||||||
sessiond: { capabilities: [] },
|
sessiond: { capabilities: [] },
|
||||||
},
|
},
|
||||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
|
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +33,59 @@ describe("PI WEB status parsing", () => {
|
|||||||
})).toBeUndefined();
|
})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses and freezes a session daemon active agent profile", () => {
|
||||||
|
const parsed = parsePiWebRuntimeResponse({
|
||||||
|
packageName: "@jmfederico/pi-web",
|
||||||
|
generatedAt: "now",
|
||||||
|
components: {
|
||||||
|
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
|
||||||
|
sessiond: {
|
||||||
|
component: "sessiond",
|
||||||
|
label: "Session daemon",
|
||||||
|
available: true,
|
||||||
|
capabilities: [],
|
||||||
|
activeAgentProfile: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: "/opt/acme-agent/state",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.components.sessiond.activeAgentProfile).toMatchObject({ command: "acme-agent", dir: "/opt/acme-agent/state" });
|
||||||
|
expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile)).toBe(true);
|
||||||
|
expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile?.sessionDirEnvKeys)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed, secret-bearing, or web-owned active profile descriptors", () => {
|
||||||
|
const profile = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: `sha256:${"a".repeat(64)}`,
|
||||||
|
command: "acme-agent",
|
||||||
|
dir: "/opt/acme-agent/state",
|
||||||
|
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
|
||||||
|
};
|
||||||
|
const responseFor = (webProfile: unknown, sessiondProfile: unknown) => ({
|
||||||
|
packageName: "@jmfederico/pi-web",
|
||||||
|
generatedAt: "now",
|
||||||
|
components: {
|
||||||
|
web: { component: "web", label: "Web/UI", available: true, capabilities: [], ...(webProfile === undefined ? {} : { activeAgentProfile: webProfile }) },
|
||||||
|
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], ...(sessiondProfile === undefined ? {} : { activeAgentProfile: sessiondProfile }) },
|
||||||
|
},
|
||||||
|
capabilities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, token: "secret" }))).toBeUndefined();
|
||||||
|
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, command: "./acme-agent" }))).toBeUndefined();
|
||||||
|
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, dir: "relative/state" }))).toBeUndefined();
|
||||||
|
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] }))).toBeUndefined();
|
||||||
|
expect(parsePiWebRuntimeResponse(responseFor(profile, undefined))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("parses Docker installation metadata", () => {
|
it("parses Docker installation metadata", () => {
|
||||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
||||||
kind: "docker",
|
kind: "docker",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
||||||
|
import { parseActiveAgentProfileDescriptor } from "./activeAgentProfile.js";
|
||||||
import { parseKnownPiWebCapabilities } from "./capabilities.js";
|
import { parseKnownPiWebCapabilities } from "./capabilities.js";
|
||||||
|
|
||||||
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
||||||
@@ -33,15 +34,19 @@ export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponen
|
|||||||
const runtimeVersion = value["runtimeVersion"];
|
const runtimeVersion = value["runtimeVersion"];
|
||||||
const available = value["available"];
|
const available = value["available"];
|
||||||
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
|
||||||
|
const activeAgentProfileValue = value["activeAgentProfile"];
|
||||||
|
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
|
||||||
const error = value["error"];
|
const error = value["error"];
|
||||||
if (component !== "web" && component !== "sessiond") return undefined;
|
if (component !== "web" && component !== "sessiond") return undefined;
|
||||||
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
||||||
|
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) return undefined;
|
||||||
return {
|
return {
|
||||||
component,
|
component,
|
||||||
label,
|
label,
|
||||||
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
||||||
available,
|
available,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
...(activeAgentProfile === undefined ? {} : { activeAgentProfile }),
|
||||||
...(typeof error === "string" ? { error } : {}),
|
...(typeof error === "string" ? { error } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user