mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 06:13:26 +00:00
fix(opencode): harden the shared V2 runtime
Remove the duplicate Tauri native event transport and use the server EventSource stream consistently across web, Electron, and Tauri clients. Align the generated client and required opencode2 CLI on next-17353. Restrict workspace proxying to the required V2 API surface, enforce session and directory ownership, validate local file URIs, strip routing headers, and tighten provider, Yolo, worktree, and session event state handling. Protect the shared service lifecycle with private registration state, process-identity leases, safe ownership transfer, bounded shutdown, Windows executable resolution, and WSL namespace-aware PID verification. Unknown wrappers now fail closed rather than leaving an unowned service. Validated with server and UI typechecks, 433 UI tests, focused server lifecycle and proxy suites, 67 Rust tests, server and UI builds, and a real next-17353 service lifecycle smoke test.
This commit is contained in:
parent
07f29d08d5
commit
c66aac7967
70 changed files with 2817 additions and 2708 deletions
|
|
@ -15,7 +15,7 @@ description: |
|
|||
|
||||
## Native OpenCode V2 Baseline
|
||||
|
||||
- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17288` in server and UI.
|
||||
- The only OpenCode client dependency is exact version `@opencode-ai/client@0.0.0-next-17353` in server and UI.
|
||||
- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`.
|
||||
- There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging.
|
||||
- The server owns one shared OpenCode service through `OpenCodeSharedService` and upstream `Service.ensure`; workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes.
|
||||
|
|
@ -56,7 +56,7 @@ description: |
|
|||
|
||||
| Avoid | Use |
|
||||
|---|---|
|
||||
| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17288` |
|
||||
| `@opencode-ai/sdk` | `@opencode-ai/client@0.0.0-next-17353` |
|
||||
| One `opencode serve` per workspace | One `Service.ensure` shared service |
|
||||
| Per-worktree clients/processes | Root proxy client plus native location/directory inputs |
|
||||
| Reintroducing `packages/opencode-plugin` | Native OpenCode Shell/instructions |
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ The server calls `Service.ensure` once through `packages/server/src/workspaces/o
|
|||
|
||||
| Owner | Responsibilities | Main paths |
|
||||
|---|---|---|
|
||||
| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17288` |
|
||||
| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell and instructions | `@opencode-ai/client@0.0.0-next-17353` |
|
||||
| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` |
|
||||
| CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` |
|
||||
| Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Package
|
||||
|
||||
CodeNomad pins `@opencode-ai/client@0.0.0-next-17288` exactly in both `packages/server/package.json` and `packages/ui/package.json`.
|
||||
CodeNomad pins `@opencode-ai/client@0.0.0-next-17353` exactly in both `packages/server/package.json` and `packages/ui/package.json`.
|
||||
|
||||
- Promise client: `import { OpenCode } from "@opencode-ai/client"`
|
||||
- Service lifecycle: `import { Service } from "@opencode-ai/client/service"`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Contract
|
||||
|
||||
- Version is pinned to `@opencode-ai/client@0.0.0-next-17288`; update server and UI together.
|
||||
- Version is pinned to `@opencode-ai/client@0.0.0-next-17353`; update server, UI, and the required `opencode2` CLI together.
|
||||
- The package root is the generated zero-Effect Promise client. Use installed declarations, not old SDK examples.
|
||||
- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy.
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Then open a pull request on GitHub targeting the `dev` branch.
|
|||
|
||||
### OpenCode V2 Boundaries
|
||||
|
||||
- Server and UI pin `@opencode-ai/client@0.0.0-next-17288`; do not add `@opencode-ai/sdk`.
|
||||
- Server and UI pin `@opencode-ai/client@0.0.0-next-17353`; do not add `@opencode-ai/sdk`.
|
||||
- `packages/server/src/workspaces/opencode-service.ts` owns the single shared `Service.ensure` lifecycle. Workspaces are native OpenCode locations/directories, not separate server processes.
|
||||
- OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`.
|
||||
- Native `client.session.shell` and `client.session.instructions.entry` cover Shell and prompt instructions. There is no `packages/opencode-plugin` integration.
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ The migration removes the V1 compatibility layer rather than maintaining both in
|
|||
|
||||
## Main Changes
|
||||
|
||||
- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17288`.
|
||||
- Replace `@opencode-ai/sdk` with the pinned native V2 client, `@opencode-ai/client@0.0.0-next-17353`.
|
||||
- Use one shared OpenCode V2 service instead of one runtime per workspace.
|
||||
- Represent CodeNomad workspaces as logical instances associated with absolute directories.
|
||||
- Use native `Location` and `SessionInfo.location` data to associate sessions, files, events, and Git worktrees.
|
||||
- Migrate sessions, messages, streaming events, permissions, questions, files, VCS, commands, MCP, providers, models, and agents to native V2 APIs.
|
||||
- Handle native text, reasoning, tool, status, and terminal session events.
|
||||
- Use browser `EventSource` as the single desktop and web event transport; the duplicate Rust-native Tauri transport was removed.
|
||||
- Reconcile session state through `session.active()` after reconnecting so missed events do not leave stale working states.
|
||||
- Route events from owned Git worktrees to their corresponding logical CodeNomad workspace.
|
||||
|
||||
|
|
@ -53,21 +54,17 @@ The migration removes the V1 compatibility layer rather than maintaining both in
|
|||
## Current Status
|
||||
|
||||
- Server and UI typechecks pass.
|
||||
- Focused tests for service ownership, proxy security, worktree event routing, provider authentication, and voice instructions pass.
|
||||
- UI tests and builds passed earlier in the migration.
|
||||
- A real service smoke test is blocked because `opencode2` is not installed in the current `PATH`.
|
||||
- The migration is not merge-ready yet. The final security review found unresolved proxy isolation issues that must be fixed first.
|
||||
- The complete UI suite passes with 433 tests.
|
||||
- Focused proxy, service ownership, worktree event routing, provider authentication, voice instruction, Windows, WSL, and Tauri tests pass.
|
||||
- The pinned client and installed `opencode2` CLI are aligned on `0.0.0-next-17353`.
|
||||
- The final critical/high security gate has no unresolved proxy, authentication, event-isolation, or process-ownership finding.
|
||||
- A real `opencode2@0.0.0-next-17353` lifecycle smoke test passed: authenticated discovery, workspace location validation, and confirmed service shutdown.
|
||||
- The migration remains a Draft until the GitHub build matrix completes.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
- Fix the encoded-path proxy issue that can redirect an authenticated upstream request to another host.
|
||||
- Restrict or filter global V2 endpoints that are not scoped by `Location`.
|
||||
- Enforce session ownership for experimental session log routes.
|
||||
- Validate embedded locations when importing sessions.
|
||||
- Harden shared service registration and multi-process shutdown behavior.
|
||||
- Complete the remaining high-priority event and provider-auth security fixes.
|
||||
- Run the complete test and build matrix after the fixes.
|
||||
- Run an end-to-end smoke test with the actual `opencode2` binary.
|
||||
- Run the GitHub build matrix by marking the PR ready for review.
|
||||
|
||||
## Validation
|
||||
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ tasks/ Task tracking
|
|||
|
||||
## Current OpenCode Baseline
|
||||
|
||||
- Native client: `@opencode-ai/client@0.0.0-next-17288`
|
||||
- Native client and required CLI: `@opencode-ai/client@0.0.0-next-17353` / `opencode2@0.0.0-next-17353`
|
||||
- Service: one shared `Service.ensure`
|
||||
- Workspaces: native locations/directories
|
||||
- Shell and instructions: native session APIs
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17288`.
|
||||
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. It integrates directly with native OpenCode V2 through exact dependency `@opencode-ai/client@0.0.0-next-17353`.
|
||||
|
||||
```text
|
||||
Desktop host -> CodeNomad server -> one shared OpenCode service
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## OpenCode Dependency
|
||||
|
||||
Server and UI pin `@opencode-ai/client@0.0.0-next-17288`. Import the generated Promise client from `@opencode-ai/client` and service lifecycle APIs from `@opencode-ai/client/service`.
|
||||
Server and UI pin `@opencode-ai/client@0.0.0-next-17353`. Import the generated Promise client from `@opencode-ai/client`; CodeNomad owns the safe shared-service launch lifecycle because the beta service helper does not expose sufficient process-identity guarantees.
|
||||
|
||||
Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpencodeClient()`, or a `packages/opencode-plugin` package. Verify method signatures in `node_modules/@opencode-ai/client/dist/promise/`.
|
||||
|
||||
|
|
|
|||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -3303,13 +3303,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/client": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-9qD73yHk4zpIafusE5NQ/fMAQhm9TdEmiocy3niuL6VuTaUzy7/18gjqYbUAjmKriCPf3G/ue+l44JC/P8n8rw==",
|
||||
"version": "0.0.0-next-17353",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17353.tgz",
|
||||
"integrity": "sha512-LuIl8eL3ZLpoiPBNHvBVn8bcsQnxEvplf5nC83RN5PUK1Wo2pKGTW+e22VMqRhmTz12mxH8n0W4ys5/SFtbyiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "0.0.0-next-17288",
|
||||
"@opencode-ai/schema": "0.0.0-next-17288"
|
||||
"@opencode-ai/protocol": "0.0.0-next-17353",
|
||||
"@opencode-ai/schema": "0.0.0-next-17353"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101"
|
||||
|
|
@ -3321,19 +3321,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/protocol": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-UlKX6+3ShWAJF6Ctq5yvJQbBvpb48GFeFaGSyZWTkqNdzG9kCbiXpxo6/bMUps+JyS7yQ7tdNcn9vfSNZ0QFEA==",
|
||||
"version": "0.0.0-next-17353",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17353.tgz",
|
||||
"integrity": "sha512-QnCRsd3hznEXsTAQxloFBA97VQC3gHFGjB91ugZjmvKzcWYVvuBjMp86GZzv1ZWsmUCemiIwvyp6v4d7hjg/SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "0.0.0-next-17288",
|
||||
"@opencode-ai/schema": "0.0.0-next-17353",
|
||||
"effect": "4.0.0-beta.101"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/schema": {
|
||||
"version": "0.0.0-next-17288",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17288.tgz",
|
||||
"integrity": "sha512-c/raCR/Es3UXada+7ZpL/nabjBtSV5QPS11sL3mmfmryMtsrZE4DSnnG5I7ihwwr9ZpR8taAmXbyh0fzq6h3lQ==",
|
||||
"version": "0.0.0-next-17353",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17353.tgz",
|
||||
"integrity": "sha512-7ZfJmXOsMI16w0UpqRvtwh3Dice4DG0irufuNqIINUc2aHWL8LOpZkpyhu+n9KKbCYOj/VMeWGLwhx8u5KIoNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
|
@ -13654,7 +13654,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17353",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
@ -13704,7 +13704,7 @@
|
|||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17353",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17353",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,69 @@ describe("AutoAcceptManager session tree", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("updates family boundaries from native revert events", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
publishSession(bus, "inst", "session.updated", { id: "root", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" })
|
||||
manager.toggle("inst", "root")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "session.revert.staged",
|
||||
properties: { sessionID: "child", revert: { messageID: "message" } },
|
||||
})
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "staged", sessionID: "child" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "session.revert.cleared",
|
||||
properties: { sessionID: "child" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged"])
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "session.revert.staged",
|
||||
properties: { sessionID: "child", revert: { messageID: "message" } },
|
||||
})
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "committed", sessionID: "child" },
|
||||
})
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "session.revert.committed",
|
||||
properties: { sessionID: "child" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged", "committed"])
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("does not apply Yolo policy to a session unknown to that logical workspace", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
manager.toggle("wrong-owner", "session")
|
||||
publishInstanceEvent(bus, "wrong-owner", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "permission", sessionID: "session" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
publishSession(bus, "wrong-owner", "session.updated", { id: "session", parentID: null })
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 1)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("session.deleted removes the tree entry but keeps the toggle", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
|
|
@ -129,6 +192,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const writes: unknown[][] = []
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist(...args) { writes.push(args); await gate },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -148,6 +212,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
const writes: boolean[] = []
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -181,6 +246,20 @@ describe("AutoAcceptManager persistence", () => {
|
|||
manager.stop()
|
||||
})
|
||||
|
||||
it("rejects a persisted toggle when the native session belongs to another logical workspace", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
let writes = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "foreign", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return null },
|
||||
async persist() { writes += 1 },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
await assert.rejects(Promise.resolve(manager.toggle("inst", "foreign")), /does not belong to workspace/)
|
||||
assert.equal(writes, 0)
|
||||
assert.equal(manager.isEnabled("inst", "foreign"), false)
|
||||
})
|
||||
|
||||
it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const changes: Record<string, unknown>[] = []
|
||||
|
|
@ -190,6 +269,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
let writes = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist() { writes += 1; await gate },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -245,6 +325,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
{ id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
|
||||
]
|
||||
},
|
||||
async loadSession() { return { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true } },
|
||||
async persist(...args) {
|
||||
writes.push(args)
|
||||
if (writes.length === 1) await firstGate
|
||||
|
|
@ -273,6 +354,7 @@ describe("AutoAcceptManager persistence", () => {
|
|||
let attempts = 0
|
||||
const persistence: AutoAcceptPersistence = {
|
||||
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
|
||||
async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } },
|
||||
async persist() { if (++attempts === 1) throw new Error("write failed") },
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
|
||||
|
|
@ -301,6 +383,25 @@ describe("AutoAcceptManager persistence", () => {
|
|||
})
|
||||
|
||||
describe("AutoAcceptManager permission interception", () => {
|
||||
it("replies once when duplicate logical workspaces receive the same native permission", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
for (const instanceId of ["first", "second"]) {
|
||||
publishSession(bus, instanceId, "session.updated", { id: "session", parentID: null })
|
||||
manager.toggle(instanceId, "session")
|
||||
publishInstanceEvent(bus, instanceId, {
|
||||
type: "permission.asked",
|
||||
properties: { id: "permission", sessionID: "session" },
|
||||
})
|
||||
}
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("auto-replies to a v2 permission on an enabled family", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo {
|
|||
|
||||
export interface AutoAcceptPersistence {
|
||||
loadSessions(instanceId: string): Promise<PersistedAutoAcceptSession[]>
|
||||
loadSession?(instanceId: string, sessionId: string): Promise<PersistedAutoAcceptSession | null>
|
||||
persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -56,15 +57,16 @@ const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked",
|
|||
const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"])
|
||||
const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"])
|
||||
const SESSION_REMOVE_TYPES = new Set(["session.deleted"])
|
||||
const SESSION_REVERT_TYPES = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"])
|
||||
|
||||
export class AutoAcceptManager {
|
||||
private static readonly MAX_REPLY_ATTEMPTS = 3
|
||||
private readonly store = new AutoAcceptStore()
|
||||
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
|
||||
/** Native permission ids currently being replied, including duplicate logical workspace emissions. */
|
||||
private readonly inFlight = new Set<string>()
|
||||
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
|
||||
private readonly pending = new Map<string, Map<string, PendingPermission>>()
|
||||
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
|
||||
/** Native permission id -> failure count, to stop retrying stuck permissions. */
|
||||
private readonly replyAttempts = new Map<string, number>()
|
||||
private readonly hydratedInstances = new Set<string>()
|
||||
private readonly hydration = new Map<string, Promise<void>>()
|
||||
|
|
@ -177,6 +179,17 @@ export class AutoAcceptManager {
|
|||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
|
||||
return this.store.isEnabled(instanceId, sessionId)
|
||||
}
|
||||
const session = await this.deps.persistence!.loadSession?.(instanceId, sessionId)
|
||||
if (!session) throw new Error(`Session ${sessionId} does not belong to workspace ${instanceId}`)
|
||||
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
|
||||
return this.store.isEnabled(instanceId, sessionId)
|
||||
}
|
||||
this.store.upsertSession(instanceId, session)
|
||||
if (session.workspaceId) {
|
||||
const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map<string, string>()
|
||||
workspaces.set(session.id, session.workspaceId)
|
||||
this.sessionWorkspaces.set(instanceId, workspaces)
|
||||
}
|
||||
const rootSessionId = this.store.familyRoot(instanceId, sessionId)
|
||||
const traversedRootSessionIds = new Set([rootSessionId])
|
||||
const enabled = !this.store.isEnabled(instanceId, rootSessionId)
|
||||
|
|
@ -240,13 +253,6 @@ export class AutoAcceptManager {
|
|||
this.mutations.delete(instanceId)
|
||||
this.store.clearInstance(instanceId)
|
||||
this.pending.delete(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(this.inFlight.keys())) {
|
||||
if (key.startsWith(prefix)) this.inFlight.delete(key)
|
||||
}
|
||||
for (const key of Array.from(this.replyAttempts.keys())) {
|
||||
if (key.startsWith(prefix)) this.replyAttempts.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void {
|
||||
|
|
@ -265,6 +271,10 @@ export class AutoAcceptManager {
|
|||
}
|
||||
return
|
||||
}
|
||||
if (SESSION_REVERT_TYPES.has(event.type)) {
|
||||
this.ingestSessionRevert(instanceId, event.type, event.properties)
|
||||
return
|
||||
}
|
||||
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
|
||||
this.handlePermissionReplied(instanceId, event.properties)
|
||||
return
|
||||
|
|
@ -300,6 +310,16 @@ export class AutoAcceptManager {
|
|||
this.drainPending(instanceId, session.id)
|
||||
}
|
||||
|
||||
private ingestSessionRevert(instanceId: string, eventType: string, properties: unknown): void {
|
||||
const value = properties as { sessionID?: unknown; sessionId?: unknown; revert?: unknown } | undefined
|
||||
const sessionId = readString(value?.sessionID) ?? readString(value?.sessionId)
|
||||
if (!sessionId || !this.store.hasSession(instanceId, sessionId)) return
|
||||
const enabledBefore = this.store.enabledRoots(instanceId)
|
||||
this.store.setSessionRevert(instanceId, sessionId, eventType === "session.revert.staged" ? value?.revert : undefined)
|
||||
this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId))
|
||||
this.drainPending(instanceId, sessionId)
|
||||
}
|
||||
|
||||
private persistRootMigration(instanceId: string, before: readonly string[], after: readonly string[]): void {
|
||||
if (!this.deps.persistence) return
|
||||
const removed = before.filter((id) => !after.includes(id))
|
||||
|
|
@ -354,7 +374,7 @@ export class AutoAcceptManager {
|
|||
|
||||
this.addPending(instanceId, { permissionId, sessionId, source })
|
||||
|
||||
if (!this.store.isEnabled(instanceId, sessionId)) return
|
||||
if (!this.store.hasSession(instanceId, sessionId) || !this.store.isEnabled(instanceId, sessionId)) return
|
||||
this.tryAutoAccept(instanceId, permissionId, sessionId, source)
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +395,7 @@ export class AutoAcceptManager {
|
|||
sessionId: string,
|
||||
source: PermissionSource,
|
||||
): void {
|
||||
const key = `${instanceId}:${permissionId}`
|
||||
const key = permissionId
|
||||
if (this.inFlight.has(key)) return
|
||||
const attempts = this.replyAttempts.get(key) ?? 0
|
||||
if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return
|
||||
|
|
@ -387,13 +407,13 @@ export class AutoAcceptManager {
|
|||
void this.deps.replier(reply)
|
||||
.then(() => {
|
||||
this.replyAttempts.delete(key)
|
||||
this.removePending(instanceId, permissionId)
|
||||
this.removePendingFromAllInstances(permissionId)
|
||||
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed")
|
||||
if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) {
|
||||
this.removePending(instanceId, permissionId)
|
||||
this.removePendingFromAllInstances(permissionId)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -407,7 +427,7 @@ export class AutoAcceptManager {
|
|||
if (!instancePending || instancePending.size === 0) return
|
||||
const root = this.store.familyRoot(instanceId, sessionId)
|
||||
for (const entry of Array.from(instancePending.values())) {
|
||||
if (this.store.familyRoot(instanceId, entry.sessionId) === root) {
|
||||
if (this.store.hasSession(instanceId, entry.sessionId) && this.store.familyRoot(instanceId, entry.sessionId) === root) {
|
||||
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source)
|
||||
}
|
||||
}
|
||||
|
|
@ -425,18 +445,22 @@ export class AutoAcceptManager {
|
|||
private removePending(instanceId: string, permissionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (instancePending?.delete(permissionId)) {
|
||||
this.replyAttempts.delete(`${instanceId}:${permissionId}`)
|
||||
this.replyAttempts.delete(permissionId)
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingFromAllInstances(permissionId: string): void {
|
||||
for (const instanceId of Array.from(this.pending.keys())) this.removePending(instanceId, permissionId)
|
||||
}
|
||||
|
||||
private removePendingForSession(instanceId: string, sessionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (!instancePending) return
|
||||
for (const [permId, entry] of Array.from(instancePending)) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
instancePending.delete(permId)
|
||||
this.replyAttempts.delete(`${instanceId}:${permId}`)
|
||||
this.replyAttempts.delete(permId)
|
||||
}
|
||||
}
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ export class AutoAcceptStore {
|
|||
this.sessions.get(instanceId)?.delete(sessionId)
|
||||
}
|
||||
|
||||
hasSession(instanceId: string, sessionId: string): boolean {
|
||||
return this.sessions.get(instanceId)?.has(sessionId) ?? false
|
||||
}
|
||||
|
||||
setSessionRevert(instanceId: string, sessionId: string, revert: unknown): void {
|
||||
const session = this.sessions.get(instanceId)?.get(sessionId)
|
||||
if (!session) return
|
||||
session.revert = revert
|
||||
this.migrateEnabledRoots(instanceId)
|
||||
}
|
||||
|
||||
clearInstance(instanceId: string): void {
|
||||
this.sessions.delete(instanceId)
|
||||
this.enabled.delete(instanceId)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ function createHarness() {
|
|||
return owner
|
||||
},
|
||||
} as unknown as SettingsService
|
||||
const workspaceManager = { get: () => ({ path: "/repo" }) } as unknown as WorkspaceManager
|
||||
const workspaceManager = {
|
||||
get: () => ({ path: "/repo" }),
|
||||
ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo",
|
||||
} as unknown as WorkspaceManager
|
||||
const client = {
|
||||
session: {
|
||||
async list(input: Record<string, unknown>) {
|
||||
|
|
@ -35,6 +38,14 @@ function createHarness() {
|
|||
cursor: {},
|
||||
}
|
||||
},
|
||||
async get({ sessionID }: { sessionID: string }) {
|
||||
return {
|
||||
id: sessionID,
|
||||
parentID: undefined,
|
||||
revert: undefined,
|
||||
location: { directory: sessionID === "foreign" ? "/other" : "/repo", workspaceID: "workspace" },
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
const persistence = createOpencodeYoloPersistence(
|
||||
|
|
@ -61,4 +72,10 @@ describe("OpenCode Yolo persistence", () => {
|
|||
])
|
||||
})
|
||||
|
||||
it("loads an exact session only when its native location belongs to the logical workspace", async () => {
|
||||
const { persistence } = createHarness()
|
||||
assert.equal((await persistence.loadSession!("instance", "root"))?.id, "root")
|
||||
assert.equal(await persistence.loadSession!("instance", "foreign"), null)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import { createInstanceClient } from "../workspaces/instance-client"
|
||||
|
|
@ -43,6 +43,13 @@ export function createOpencodeYoloPersistence(
|
|||
limit: SESSION_LIST_LIMIT,
|
||||
})).data
|
||||
}
|
||||
const persistedSession = (session: SessionInfo): PersistedAutoAcceptSession => ({
|
||||
id: session.id,
|
||||
parentId: session.parentID ?? null,
|
||||
revert: session.revert,
|
||||
workspaceId: session.location.workspaceID,
|
||||
yoloEnabled: sessionState(settings, session.id).yoloEnabled === true,
|
||||
})
|
||||
const updateYolo = (
|
||||
sessionId: string,
|
||||
enabled: boolean,
|
||||
|
|
@ -60,13 +67,16 @@ export function createOpencodeYoloPersistence(
|
|||
|
||||
return {
|
||||
async loadSessions(instanceId): Promise<PersistedAutoAcceptSession[]> {
|
||||
return (await listSessions(instanceId)).map((session) => ({
|
||||
id: session.id,
|
||||
parentId: session.parentID ?? null,
|
||||
revert: session.revert,
|
||||
workspaceId: session.location.workspaceID,
|
||||
yoloEnabled: sessionState(settings, session.id).yoloEnabled === true,
|
||||
}))
|
||||
return (await listSessions(instanceId)).map(persistedSession)
|
||||
},
|
||||
async loadSession(instanceId, sessionId): Promise<PersistedAutoAcceptSession | null> {
|
||||
try {
|
||||
const session = await (await clientFor(instanceId)).session.get({ sessionID: sessionId })
|
||||
if (!(await workspaceManager.ownsDirectory(instanceId, session.location.directory))) return null
|
||||
return persistedSession(session)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
persist(_instanceId, rootSessionId, enabled): Promise<void> {
|
||||
return updateYolo(rootSessionId, enabled)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ function logger(): Logger {
|
|||
return value as unknown as Logger
|
||||
}
|
||||
|
||||
async function harness(sessionDirectory = "/repo/worktree") {
|
||||
async function harness(
|
||||
sessionDirectory = "/repo/worktree",
|
||||
activeSessions: Record<string, { type: "running" }> = {},
|
||||
sessionLocations: Record<string, string | Error> = {},
|
||||
) {
|
||||
const upstream = Fastify()
|
||||
apps.push(upstream)
|
||||
let requests = 0
|
||||
|
|
@ -33,8 +37,11 @@ async function harness(sessionDirectory = "/repo/worktree") {
|
|||
session: {
|
||||
get: async ({ sessionID }: { sessionID: string }) => {
|
||||
sessionGets.push(sessionID)
|
||||
return { id: sessionID, location: { directory: sessionDirectory } } as SessionInfo
|
||||
const location = sessionLocations[sessionID] ?? sessionDirectory
|
||||
if (location instanceof Error) throw location
|
||||
return { id: sessionID, location: { directory: location } } as SessionInfo
|
||||
},
|
||||
active: async () => activeSessions,
|
||||
},
|
||||
} as OpenCodeClient
|
||||
const manager: InstanceProxyWorkspaceManager = {
|
||||
|
|
@ -43,6 +50,7 @@ async function harness(sessionDirectory = "/repo/worktree") {
|
|||
getInstanceAuthorizationHeader: () => "Basic internal-secret",
|
||||
getSharedServiceClient: async () => client,
|
||||
ownsDirectory: async (_id, directory) => owned.has(directory),
|
||||
ownsPath: async (_id, candidate) => candidate === "/repo" || candidate.startsWith("/repo/"),
|
||||
}
|
||||
const app = Fastify()
|
||||
apps.push(app)
|
||||
|
|
@ -118,6 +126,9 @@ describe("instance proxy location enforcement", () => {
|
|||
connection: "keep-alive, x-remove-me",
|
||||
cookie: "codenomad_session=browser-secret; other=value",
|
||||
"x-forwarded-for": "203.0.113.1",
|
||||
"x-opencode-directory": "/other",
|
||||
"x-opencode-workspace": "foreign-workspace",
|
||||
"x-opencode-routing-test": "foreign-route",
|
||||
"x-remove-me": "secret",
|
||||
},
|
||||
})
|
||||
|
|
@ -126,6 +137,9 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(headers.cookie, undefined)
|
||||
assert.doesNotMatch(headers.connection ?? "", /x-remove-me/i)
|
||||
assert.equal(headers["x-forwarded-for"], undefined)
|
||||
assert.equal(headers["x-opencode-directory"], undefined)
|
||||
assert.equal(headers["x-opencode-workspace"], undefined)
|
||||
assert.equal(headers["x-opencode-routing-test"], undefined)
|
||||
assert.equal(headers["x-remove-me"], undefined)
|
||||
assert.equal(response.headers["set-cookie"], undefined)
|
||||
})
|
||||
|
|
@ -145,6 +159,168 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(requestCount(), 0)
|
||||
assert.doesNotMatch(response.body, /internal-secret/)
|
||||
})
|
||||
|
||||
it("uses the same once-decoded percent-bearing session id for ownership and forwarding", async () => {
|
||||
const { app, sessionGets } = await harness()
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/workspaces/workspace/instance/api/session/owned%25session",
|
||||
})
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(sessionGets, ["owned%session"])
|
||||
assert.equal(JSON.parse(response.body).url, "/api/session/owned%25session")
|
||||
})
|
||||
|
||||
it("rejects deletion through a double-encoded alias of a foreign session", async () => {
|
||||
const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, {
|
||||
"foreign%25session": "/other",
|
||||
})
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/workspaces/workspace/instance/api/session/foreign%2525session",
|
||||
})
|
||||
assert.equal(response.statusCode, 403)
|
||||
assert.deepEqual(sessionGets, ["foreign%25session"])
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("filters active sessions to the workspace without failing on stale ids", async () => {
|
||||
const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } }
|
||||
const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, {
|
||||
foreign: "/other",
|
||||
stale: new Error("missing"),
|
||||
})
|
||||
const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(JSON.parse(response.body), { data: { owned: { type: "running" } } })
|
||||
assert.deepEqual(sessionGets.sort(), ["foreign", "owned", "stale"])
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("blocks global routes through a workspace", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
for (const route of ["global/dispose", "global/config", "global/upgrade"]) {
|
||||
const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/${route}` })
|
||||
assert.equal(response.statusCode, 403)
|
||||
}
|
||||
for (const route of ["event", "project", "debug/location"]) {
|
||||
const response = await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/${route}` })
|
||||
assert.equal(response.statusCode, 403)
|
||||
}
|
||||
assert.equal((await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/service/stop",
|
||||
payload: { instanceID: "instance-1" },
|
||||
})).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/permission/saved" })).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/permission/saved/global-rule" })).statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("rejects legacy session routes before foreign session lookup", async () => {
|
||||
const { app, sessionGets, requestCount } = await harness("/other")
|
||||
const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/session/foreign" })
|
||||
assert.equal(response.statusCode, 403)
|
||||
assert.deepEqual(sessionGets, [])
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("rejects foreign prompt file URIs and accepts owned files", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
const malformed = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/session-1/prompt",
|
||||
payload: { text: "read this", files: [{ uri: "file:///%ZZ" }] },
|
||||
})
|
||||
assert.equal(malformed.statusCode, 400)
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/session-1/prompt",
|
||||
payload: { text: "read this", files: [{ uri: "file:///other/secret.txt" }] },
|
||||
})
|
||||
assert.equal(foreign.statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
|
||||
const owned = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/session-1/prompt",
|
||||
payload: { text: "read this", files: [{ uri: "file:///repo/worktree/notes.txt" }] },
|
||||
})
|
||||
assert.equal(owned.statusCode, 200)
|
||||
assert.equal(JSON.parse(owned.body).body.files[0].uri, "file:///repo/worktree/notes.txt")
|
||||
assert.equal(requestCount(), 1)
|
||||
})
|
||||
|
||||
it("enforces ownership for experimental session logs", async () => {
|
||||
const owned = await harness()
|
||||
const accepted = await owned.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-1/log" })
|
||||
assert.equal(accepted.statusCode, 200)
|
||||
assert.deepEqual(owned.sessionGets, ["session-1"])
|
||||
|
||||
const foreign = await harness("/other")
|
||||
const rejected = await foreign.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-2/log" })
|
||||
assert.equal(rejected.statusCode, 403)
|
||||
assert.equal(foreign.requestCount(), 0)
|
||||
})
|
||||
|
||||
it("defaults and validates only schema-defined imported session locations", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
const accepted = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/import",
|
||||
payload: {
|
||||
info: { id: "session-1", metadata: { location: { directory: "/other" } } },
|
||||
messages: [{
|
||||
type: "location-switched",
|
||||
location: { directory: "/repo/worktree" },
|
||||
previous: { location: null },
|
||||
metadata: { location: { directory: "/other" } },
|
||||
content: [{ type: "tool", state: { input: { location: "/other" } } }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
assert.equal(accepted.statusCode, 200)
|
||||
const body = JSON.parse(accepted.body).body
|
||||
assert.deepEqual(body.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.info.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.messages[0].previous.location, { directory: "/repo" })
|
||||
assert.deepEqual(body.messages[0].metadata.location, { directory: "/other" })
|
||||
assert.equal(body.messages[0].content[0].state.input.location, "/other")
|
||||
|
||||
const rejected = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/import",
|
||||
payload: {
|
||||
info: { id: "session-2", location: { directory: "/repo" } },
|
||||
messages: [{ type: "location-switched", location: { directory: "/repo/worktree" }, previous: { location: { directory: "/other" } } }],
|
||||
},
|
||||
})
|
||||
assert.equal(rejected.statusCode, 403)
|
||||
assert.equal(requestCount(), 1)
|
||||
})
|
||||
|
||||
it("never sends workspace credentials to an encoded or backslash foreign origin", async () => {
|
||||
const foreign = Fastify()
|
||||
apps.push(foreign)
|
||||
const credentials: unknown[] = []
|
||||
foreign.all("/*", async (request) => credentials.push(request.headers.authorization))
|
||||
await foreign.listen({ host: "127.0.0.1", port: 0 })
|
||||
const address = foreign.server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
|
||||
const { app, requestCount } = await harness()
|
||||
for (const prefix of ["%2F%2F", "%5C%5C"]) {
|
||||
const proxyResponse: Awaited<ReturnType<typeof app.inject>> = await app.inject({
|
||||
method: "GET",
|
||||
url: `/workspaces/workspace/instance/${prefix}127.0.0.1:${address.port}/steal`,
|
||||
})
|
||||
assert.equal(proxyResponse.statusCode, 400)
|
||||
assert.doesNotMatch(proxyResponse.body, /internal-secret/)
|
||||
}
|
||||
assert.equal(requestCount(), 0)
|
||||
assert.deepEqual(credentials, [])
|
||||
})
|
||||
})
|
||||
|
||||
it("redacts secret-bearing fields recursively", () => {
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ export interface InstanceProxyWorkspaceManager {
|
|||
getInstanceAuthorizationHeader(id: string): string | undefined
|
||||
getSharedServiceClient(): Promise<OpenCodeClient>
|
||||
ownsDirectory(id: string, directory: string): Promise<boolean>
|
||||
ownsPath(id: string, candidate: string): Promise<boolean>
|
||||
}
|
||||
|
||||
interface InstanceProxyDeps {
|
||||
|
|
@ -563,16 +564,56 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const targetUrl = appendIncomingQuery(new URL(normalizedSuffix, endpoint.url), request.raw.url ?? "")
|
||||
const requestLocations = readRequestDirectories(targetUrl, request.body)
|
||||
readNativeCwd(targetUrl, request.body, requestLocations)
|
||||
const rawInstancePath = (request.raw.url ?? "").split("?", 1)[0]?.match(/\/instance(?:\/(.*))?$/)?.[1] ?? ""
|
||||
if (/\\|%2f|%5c/i.test(rawInstancePath)) {
|
||||
reply.code(400).send({ error: "Invalid workspace instance path" })
|
||||
return
|
||||
}
|
||||
const targetUrl = buildInstanceTargetUrl(endpoint.url, args.pathSuffix)
|
||||
if (!targetUrl) {
|
||||
reply.code(400).send({ error: "Invalid workspace instance path" })
|
||||
return
|
||||
}
|
||||
appendIncomingQuery(targetUrl, request.raw.url ?? "")
|
||||
const pathname = normalizeInstanceSuffix(args.pathSuffix)
|
||||
if (!isAllowedInstanceApiRoute(request.method, pathname)) {
|
||||
reply.code(403).send({ error: "OpenCode route is not available through a workspace" })
|
||||
return
|
||||
}
|
||||
if (pathname.replace(/\/+$/, "") === "/api/session/active") {
|
||||
if (request.method !== "GET") {
|
||||
reply.code(405).send({ error: "Method not allowed" })
|
||||
return
|
||||
}
|
||||
const client = await workspaceManager.getSharedServiceClient()
|
||||
const active = await client.session.active()
|
||||
const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => {
|
||||
try {
|
||||
const session = await client.session.get({ sessionID: sessionId })
|
||||
return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null)) })
|
||||
return
|
||||
}
|
||||
const imported = prepareSessionImport(pathname, request.method, request.body, workspace.path)
|
||||
const requestLocations = readRequestDirectories(targetUrl, imported.body)
|
||||
requestLocations.directories.push(...imported.directories)
|
||||
requestLocations.invalid ||= imported.invalid
|
||||
readNativeCwd(targetUrl, imported.body, requestLocations)
|
||||
const promptFiles = readPromptFilePaths(pathname, request.method, imported.body)
|
||||
if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) {
|
||||
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) {
|
||||
reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = getSessionRouteId(targetUrl.pathname)
|
||||
const sessionId = getSessionRouteId(pathname)
|
||||
if (sessionId) {
|
||||
let session
|
||||
try {
|
||||
|
|
@ -587,7 +628,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
}
|
||||
|
||||
const body = applyDefaultWorkspaceLocation(targetUrl, request.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId))
|
||||
const body = applyDefaultWorkspaceLocation(targetUrl, imported.body, request.method, workspace.path, requestLocations.directories.length > 0, Boolean(sessionId))
|
||||
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance")
|
||||
|
|
@ -685,7 +726,8 @@ function sanitizeInstanceProxyRequestHeaders(
|
|||
|
||||
const result: Record<string, string | string[] | undefined> = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!blocked.has(key.toLowerCase())) result[key] = value
|
||||
const normalized = key.toLowerCase()
|
||||
if (!blocked.has(normalized) && !normalized.startsWith("x-opencode-")) result[key] = value
|
||||
}
|
||||
if (authorization) result.authorization = authorization
|
||||
return result
|
||||
|
|
@ -711,6 +753,10 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
|
|||
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
|
||||
return (await Promise.all(paths.map((candidate) => manager.ownsPath(workspaceId, candidate)))).every(Boolean)
|
||||
}
|
||||
|
||||
function applyDefaultWorkspaceLocation(
|
||||
targetUrl: URL,
|
||||
body: unknown,
|
||||
|
|
@ -735,13 +781,141 @@ function applyDefaultWorkspaceLocation(
|
|||
}
|
||||
|
||||
function getSessionRouteId(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/api\/session\/([^/]+)(?:\/|$)/)
|
||||
const match = pathname.match(/^\/api\/(?:experimental\/)?session\/([^/]+)(?:\/|$)/)
|
||||
if (!match || match[1] === "active" || match[1] === "import") return null
|
||||
try {
|
||||
return decodeURIComponent(match[1])
|
||||
} catch {
|
||||
return null
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null {
|
||||
const suffix = pathSuffix ?? ""
|
||||
if (/\\|%2f|%5c/i.test(suffix)) return null
|
||||
const targetUrl = new URL(endpoint)
|
||||
const origin = targetUrl.origin
|
||||
targetUrl.pathname = normalizeInstanceSuffix(suffix).split("/").map(encodeURIComponent).join("/")
|
||||
targetUrl.search = ""
|
||||
targetUrl.hash = ""
|
||||
return targetUrl.origin === origin ? targetUrl : null
|
||||
}
|
||||
|
||||
function isAllowedInstanceApiRoute(method: string, pathname: string): boolean {
|
||||
const route = pathname.replace(/\/+$/, "")
|
||||
const allowed: Array<[string, RegExp]> = [
|
||||
["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|provider)$/],
|
||||
["GET", /^\/api\/(?:permission|question)\/request$/],
|
||||
["GET", /^\/api\/project\/current$/],
|
||||
["GET", /^\/api\/vcs\/status$/],
|
||||
["GET", /^\/api\/fs\/(?:list|read\/.+)$/],
|
||||
["POST", /^\/api\/(?:pty|shell)$/],
|
||||
["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/],
|
||||
["DELETE", /^\/api\/credential\/[^/]+$/],
|
||||
["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/],
|
||||
["GET", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],
|
||||
["DELETE", /^\/api\/integration\/[^/]+\/connect\/(?:oauth|command)\/[^/]+$/],
|
||||
["POST", /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/],
|
||||
["GET", /^\/api\/session(?:\/active)?$/],
|
||||
["POST", /^\/api\/session(?:\/import)?$/],
|
||||
["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/revert\/stage$/],
|
||||
["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/question\/[^/]+\/(?:reply|reject)$/],
|
||||
["GET", /^\/api\/experimental\/session\/[^/]+\/log$/],
|
||||
]
|
||||
return allowed.some(([allowedMethod, pattern]) => method === allowedMethod && pattern.test(route))
|
||||
}
|
||||
|
||||
function readPromptFilePaths(pathname: string, method: string, body: unknown) {
|
||||
const result = { paths: [] as string[], invalid: false }
|
||||
if (method !== "POST" || !/^\/api\/session\/[^/]+\/(?:prompt|command)\/?$/.test(pathname)) return result
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return result
|
||||
const files = (body as Record<string, unknown>).files
|
||||
if (files === undefined) return result
|
||||
if (!Array.isArray(files)) return { paths: [], invalid: true }
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== "object" || Array.isArray(file) || Buffer.isBuffer(file)) {
|
||||
result.invalid = true
|
||||
continue
|
||||
}
|
||||
const uri = (file as Record<string, unknown>).uri
|
||||
if (typeof uri !== "string" || !uri.trim()) {
|
||||
result.invalid = true
|
||||
continue
|
||||
}
|
||||
const parsed = parsePromptFileUri(uri)
|
||||
if (parsed.invalid) result.invalid = true
|
||||
else if (parsed.path) result.paths.push(parsed.path)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function parsePromptFileUri(value: string): { path?: string; invalid: boolean } {
|
||||
if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return { path: value, invalid: value.includes("\0") }
|
||||
let uri: URL
|
||||
try {
|
||||
uri = new URL(value)
|
||||
} catch {
|
||||
return { invalid: true }
|
||||
}
|
||||
if (["data:", "http:", "https:"].includes(uri.protocol)) return { invalid: false }
|
||||
if (uri.protocol !== "file:" || (uri.hostname && uri.hostname !== "localhost") || uri.search || uri.hash || /%2f|%5c/i.test(uri.pathname)) {
|
||||
return { invalid: true }
|
||||
}
|
||||
try {
|
||||
const decoded = decodeURIComponent(uri.pathname)
|
||||
const localPath = /^\/[A-Za-z]:\//.test(decoded) ? decoded.slice(1) : decoded
|
||||
return { path: localPath, invalid: !localPath || localPath.includes("\0") }
|
||||
} catch {
|
||||
return { invalid: true }
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) {
|
||||
const result = { body, directories: [] as string[], invalid: false }
|
||||
if (pathname !== "/api/session/import" || method !== "POST") return result
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) {
|
||||
result.invalid = true
|
||||
return result
|
||||
}
|
||||
|
||||
const input = body as Record<string, unknown>
|
||||
const addLocation = (owner: Record<string, unknown>, key: string) => {
|
||||
const value = owner[key]
|
||||
if (value === null || value === undefined) {
|
||||
owner[key] = { directory }
|
||||
result.directories.push(directory)
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) {
|
||||
result.invalid = true
|
||||
return
|
||||
}
|
||||
const location = value as Record<string, unknown>
|
||||
if (location.directory === null || location.directory === undefined) location.directory = directory
|
||||
if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory)
|
||||
else result.invalid = true
|
||||
}
|
||||
|
||||
addLocation(input, "location")
|
||||
if (input.info && typeof input.info === "object" && !Array.isArray(input.info) && !Buffer.isBuffer(input.info)) {
|
||||
addLocation(input.info as Record<string, unknown>, "location")
|
||||
}
|
||||
|
||||
if (Array.isArray(input.messages)) {
|
||||
for (const value of input.messages) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) continue
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type !== "location-switched") continue
|
||||
addLocation(message, "location")
|
||||
if (message.previous && typeof message.previous === "object" && !Array.isArray(message.previous) && !Buffer.isBuffer(message.previous)) {
|
||||
addLocation(message.previous as Record<string, unknown>, "location")
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ describe("buildWindowsSpawnSpec", () => {
|
|||
assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper")
|
||||
})
|
||||
|
||||
it("conservatively classifies bare commands as wrappers", () => {
|
||||
assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper")
|
||||
it("resolves an installed bare npm command to its packaged executable", () => {
|
||||
assert.equal(buildWindowsSpawnSpec("opencode2", []).processKind, "windows-direct")
|
||||
})
|
||||
|
||||
it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => {
|
||||
|
|
@ -233,22 +233,23 @@ describe("buildServiceLaunchSpec", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("uses a Node trampoline for the verbatim cmd.exe batch command", () => {
|
||||
it("passes through a non-npm cmd wrapper with its required verbatim arguments", () => {
|
||||
const launch = buildServiceLaunchSpec(String.raw`C:\Program Files\OpenCode\opencode.cmd`, ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
env: { ComSpec: "test-cmd.exe" },
|
||||
})
|
||||
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[1], "-e")
|
||||
assert.equal(launch.command[3], "test-cmd.exe")
|
||||
assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), [
|
||||
assert.equal(launch.command[0], "test-cmd.exe")
|
||||
assert.deepEqual(launch.command.slice(1), [
|
||||
"/d", "/s", "/c", String.raw`""C:\Program Files\OpenCode\opencode.cmd" serve --service"`,
|
||||
])
|
||||
assert.equal(launch.command[5], "")
|
||||
assert.equal(launch.windowsVerbatimArguments, true)
|
||||
assert.equal(launch.nativePid, false)
|
||||
assert.equal(buildServiceLaunchSpec("custom.bat", ["serve"], { platform: "win32" }).nativePid, false)
|
||||
assert.equal(buildServiceLaunchSpec("custom.ps1", ["serve"], { platform: "win32" }).nativePid, false)
|
||||
})
|
||||
|
||||
it("records a direct service contender PID", () => {
|
||||
it("launches a direct service executable without a wrapper", () => {
|
||||
const launch = buildServiceLaunchSpec("opencode.exe", ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
contenderFile: String.raw`C:\Temp\codenomad-contenders.txt`,
|
||||
|
|
@ -256,8 +257,10 @@ describe("buildServiceLaunchSpec", () => {
|
|||
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[3], "opencode.exe")
|
||||
assert.deepEqual(JSON.parse(launch.command[4] ?? "[]"), ["serve", "--service"])
|
||||
assert.equal(launch.command[5], String.raw`C:\Temp\codenomad-contenders.txt`)
|
||||
assert.equal(launch.command[6], "false")
|
||||
assert.equal(launch.nativePid, true)
|
||||
assert.equal(launch.launcherRecordsPid, true)
|
||||
})
|
||||
|
||||
it("translates shared Windows state and contender files for WSL", () => {
|
||||
|
|
@ -273,9 +276,46 @@ describe("buildServiceLaunchSpec", () => {
|
|||
)
|
||||
|
||||
assert.equal(launch.command[0], "wsl.exe")
|
||||
assert.match(launch.command[6] ?? "", /wslpath -au/)
|
||||
assert.equal(launch.command[8], contenderFile)
|
||||
const wslArgs = launch.command.slice(1)
|
||||
assert.match(wslArgs[5] ?? "", /wslpath -au/)
|
||||
assert.equal(wslArgs[7], contenderFile)
|
||||
assert.equal(launch.env?.WSLENV, "XDG_STATE_HOME/p")
|
||||
assert.equal(launch.nativePid, false)
|
||||
assert.equal(launch.wslDistro, "Ubuntu")
|
||||
})
|
||||
|
||||
it("passes configured service variables only to the launched child", () => {
|
||||
const launch = buildServiceLaunchSpec("opencode", ["serve", "--service"], {
|
||||
platform: "linux",
|
||||
env: { ...process.env, XDG_STATE_HOME: "/private/state", SERVICE_ONLY: "yes" },
|
||||
propagateEnvKeys: ["XDG_STATE_HOME", "SERVICE_ONLY"],
|
||||
})
|
||||
|
||||
assert.deepEqual(launch.command, ["opencode", "serve", "--service"])
|
||||
assert.equal(launch.env?.XDG_STATE_HOME, "/private/state")
|
||||
assert.equal(launch.env?.SERVICE_ONLY, "yes")
|
||||
})
|
||||
|
||||
it("resolves the standard Windows npm opencode2 shim to its packaged executable", { skip: process.platform !== "win32" }, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codenomad-npm-shim-"))
|
||||
const executable = path.join(root, "node_modules", "@opencode-ai", "cli", "bin", "opencode2.exe")
|
||||
mkdirSync(path.dirname(executable), { recursive: true })
|
||||
writeFileSync(executable, "")
|
||||
writeFileSync(path.join(root, "opencode2.cmd"), '@ECHO off\r\n"%~dp0\\node_modules\\@opencode-ai\\cli\\bin\\opencode2.exe" %*\r\n')
|
||||
try {
|
||||
const launch = buildServiceLaunchSpec("opencode2", ["serve", "--service"], {
|
||||
platform: "win32",
|
||||
cwd: root,
|
||||
env: { PATH: root, PATHEXT: ".CMD" },
|
||||
contenderFile: path.join(root, "contenders.txt"),
|
||||
})
|
||||
assert.equal(launch.command[0], process.execPath)
|
||||
assert.equal(launch.command[3]?.toLowerCase(), executable.toLowerCase())
|
||||
assert.equal(launch.nativePid, true)
|
||||
assert.equal(launch.launcherRecordsPid, true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -105,4 +105,27 @@ describe("InstanceEventBridge", () => {
|
|||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it("fans an event out to every logical workspace for the same directory", async () => {
|
||||
const manager = {
|
||||
list: () => [{ id: "first", path: "/repo" }, { id: "second", path: "/repo" }],
|
||||
ownsDirectory: async (_workspaceId: string, directory: string) => directory === "/repo",
|
||||
subscribeToSharedService: async (signal?: AbortSignal) => (async function* () {
|
||||
yield { id: "1", created: 1, type: "permission.asked", location: { directory: "/repo" }, data: { id: "p1" } } as OpenCodeEvent
|
||||
await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }))
|
||||
})(),
|
||||
} as unknown as WorkspaceManager
|
||||
const bus = new EventBus()
|
||||
const received: string[] = []
|
||||
bus.on("instance.event", (event) => received.push(event.instanceId))
|
||||
|
||||
const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger })
|
||||
try {
|
||||
bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any })
|
||||
await waitFor(() => received.length === 2)
|
||||
assert.deepEqual(received, ["first", "second"])
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class InstanceEventBridge {
|
|||
private readonly controller = new AbortController()
|
||||
private status: InstanceStreamStatus = "connecting"
|
||||
private task?: Promise<void>
|
||||
private readonly directoryOwners = new Map<string, { expiresAt: number; owner: Promise<string | undefined> }>()
|
||||
private readonly directoryOwners = new Map<string, { expiresAt: number; owners: Promise<string[]> }>()
|
||||
private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => {
|
||||
this.directoryOwners.clear()
|
||||
if (!this.task) this.task = this.run()
|
||||
|
|
@ -74,33 +74,35 @@ export class InstanceEventBridge {
|
|||
const directory = event.location?.directory
|
||||
if (!directory) return
|
||||
|
||||
const instanceId = await this.resolveDirectoryOwner(directory)
|
||||
if (!instanceId) return
|
||||
const instanceIds = await this.resolveDirectoryOwners(directory)
|
||||
if (instanceIds.length === 0) return
|
||||
|
||||
// The server's auto-accept boundary still reads the legacy property name.
|
||||
const compatibleEvent: InstanceStreamEvent = {
|
||||
...event,
|
||||
properties: this.compatibilityProperties(event),
|
||||
}
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent })
|
||||
for (const instanceId of instanceIds) {
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent })
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDirectoryOwner(directory: string): Promise<string | undefined> {
|
||||
private resolveDirectoryOwners(directory: string): Promise<string[]> {
|
||||
const now = Date.now()
|
||||
const cached = this.directoryOwners.get(directory)
|
||||
if (cached && cached.expiresAt > now) return cached.owner
|
||||
if (cached && cached.expiresAt > now) return cached.owners
|
||||
|
||||
const workspaces = this.options.workspaceManager.list()
|
||||
const owner = Promise.all(workspaces.map((workspace) => (
|
||||
const owners = Promise.all(workspaces.map((workspace) => (
|
||||
this.options.workspaceManager.ownsDirectory(workspace.id, directory)
|
||||
)))
|
||||
.then((ownership) => workspaces.find((_, index) => ownership[index])?.id)
|
||||
.then((ownership) => workspaces.filter((_, index) => ownership[index]).map((workspace) => workspace.id))
|
||||
.catch((error) => {
|
||||
this.options.logger.warn({ err: error, directory }, "Failed to resolve instance event directory owner")
|
||||
return undefined
|
||||
return []
|
||||
})
|
||||
this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owner })
|
||||
return owner
|
||||
this.directoryOwners.set(directory, { expiresAt: now + DIRECTORY_OWNER_CACHE_MS, owners })
|
||||
return owners
|
||||
}
|
||||
|
||||
private compatibilityProperties(event: OpenCodeEvent): Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class ControlledSharedService {
|
|||
validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = []
|
||||
evictions: LocationRef[] = []
|
||||
failEvictions = 0
|
||||
shutdownGate?: ReturnType<typeof deferred<void>>
|
||||
|
||||
async endpoint(options?: OpenCodeEnsureOptions) {
|
||||
this.assertCommand(options)
|
||||
|
|
@ -74,19 +75,22 @@ class ControlledSharedService {
|
|||
this.evictions.push(location)
|
||||
}
|
||||
|
||||
async shutdown() {}
|
||||
async shutdown() {
|
||||
await this.shutdownGate?.promise
|
||||
}
|
||||
|
||||
private assertCommand(options?: OpenCodeEnsureOptions) {
|
||||
assert.equal(options?.file, path.join(os.tmpdir(), "codenomad-opencode-v2", "opencode", "service.json"))
|
||||
assert.equal(options?.environment?.XDG_STATE_HOME, path.join(os.tmpdir(), "codenomad-opencode-v2"))
|
||||
assert.equal(
|
||||
options?.command?.[5],
|
||||
options?.environment?.CODENOMAD_SERVICE_CONTENDERS,
|
||||
)
|
||||
assert.match(options?.environment?.CODENOMAD_SERVICE_CONTENDERS ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`))
|
||||
const stateRoot = path.join(os.homedir(), ".codenomad", "state", "opencode-v2")
|
||||
assert.equal(options?.file, path.join(stateRoot, "opencode", "service.json"))
|
||||
assert.match(options?.contenderFile ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`))
|
||||
assert.match(options?.leaseFile ?? "", new RegExp(`leases[/\\\\]process-${process.pid}-.*\\.json$`))
|
||||
assert.equal(options?.command?.[0], process.execPath)
|
||||
assert.equal(options?.command?.[1], "-e")
|
||||
assert.equal(options?.command?.[3], process.execPath)
|
||||
assert.equal(options?.command?.[4], JSON.stringify(["serve", "--service"]))
|
||||
assert.equal(options?.command?.[5], options?.contenderFile)
|
||||
assert.equal(options?.launcherRecordsPid, true)
|
||||
assert.equal(options?.environment?.XDG_STATE_HOME, stateRoot)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,4 +197,17 @@ describe("workspace manager shared service lifecycle", () => {
|
|||
await harness.manager.delete(workspace.id)
|
||||
assert.equal(harness.manager.get(workspace.id), undefined)
|
||||
})
|
||||
|
||||
it("bounds a stalled shared service shutdown", async () => {
|
||||
const service = new ControlledSharedService()
|
||||
service.shutdownGate = deferred<void>()
|
||||
const { manager } = createHarness(service)
|
||||
;(manager as any).options.shutdownTimeoutMs = 10
|
||||
|
||||
await assert.rejects(manager.shutdown(), (error: unknown) => {
|
||||
assert.ok(error instanceof WorkspaceShutdownError)
|
||||
assert.match(String(error.errors[0]), /did not finish within/)
|
||||
return true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import type { Endpoint } from "@opencode-ai/client/service"
|
||||
import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { EventBus } from "../events/bus"
|
||||
|
|
@ -16,14 +14,20 @@ import { Logger } from "../logger"
|
|||
import { resolveWorkspaceIdentity } from "./workspace-identity"
|
||||
import { buildServiceLaunchSpec, parseWslUncPath } from "./spawn"
|
||||
import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service"
|
||||
import { resolveWorktreeSlugForDirectory } from "./worktree-directory"
|
||||
import {
|
||||
prepareServiceState,
|
||||
SERVICE_LEASE_DIRECTORY,
|
||||
SERVICE_REGISTRATION_FILE,
|
||||
SERVICE_STATE_ROOT,
|
||||
SERVICE_STOP_LOCK,
|
||||
} from "./service-state"
|
||||
import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktree-directory"
|
||||
|
||||
const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000
|
||||
const ORDINARY_CREATION_OWNER = ""
|
||||
const WORKSPACE_STATE = Symbol("workspaceState")
|
||||
const SERVICE_STATE_ROOT = path.join(os.tmpdir(), "codenomad-opencode-v2")
|
||||
const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json")
|
||||
const SERVICE_CONTENDER_FILE = path.join(SERVICE_STATE_ROOT, `contenders-${process.pid}-${randomUUID()}.txt`)
|
||||
const SERVICE_LEASE_FILE = path.join(SERVICE_LEASE_DIRECTORY, `process-${process.pid}-${randomUUID()}.json`)
|
||||
type ManagerTimeout = ReturnType<typeof setTimeout>
|
||||
|
||||
interface SharedService {
|
||||
|
|
@ -33,7 +37,7 @@ interface SharedService {
|
|||
validateLocation: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<LocationGetOutput>
|
||||
subscribe: (requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<AsyncIterable<OpenCodeEvent>>
|
||||
evict: (location: LocationRef, requestOptions?: { signal?: AbortSignal }, ensureOptions?: OpenCodeEnsureOptions) => Promise<void>
|
||||
shutdown: () => Promise<void>
|
||||
shutdown: (options?: { timeoutMs?: number }) => Promise<void>
|
||||
}
|
||||
|
||||
export function binaryPathsEqual(left: string, right: string, platform = process.platform): boolean {
|
||||
|
|
@ -176,6 +180,17 @@ export class WorkspaceManager {
|
|||
})) !== null
|
||||
}
|
||||
|
||||
async ownsPath(id: string, candidate: string): Promise<boolean> {
|
||||
const workspace = this.get(id)
|
||||
if (!workspace) return false
|
||||
return isPathOwnedByWorktree({
|
||||
workspaceId: id,
|
||||
workspacePath: workspace.path,
|
||||
candidate,
|
||||
logger: this.options.logger,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToSharedService(signal?: AbortSignal): Promise<AsyncIterable<OpenCodeEvent>> {
|
||||
return this.sharedService.subscribe({ signal })
|
||||
}
|
||||
|
|
@ -377,7 +392,10 @@ export class WorkspaceManager {
|
|||
}
|
||||
}, timeoutMs)
|
||||
try {
|
||||
return await this.createResolvedWorkspace(record)
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
state.abortController.signal.addEventListener("abort", () => reject(state.abortController.signal.reason), { once: true })
|
||||
})
|
||||
return await Promise.race([this.createResolvedWorkspace(record, timeoutMs), deadline])
|
||||
} finally {
|
||||
if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout)
|
||||
}
|
||||
|
|
@ -401,6 +419,7 @@ export class WorkspaceManager {
|
|||
}
|
||||
private async createResolvedWorkspace(
|
||||
record: WorkspaceRecord,
|
||||
timeoutMs: number,
|
||||
): Promise<WorkspaceCreateResult> {
|
||||
const state = record[WORKSPACE_STATE]
|
||||
const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record
|
||||
|
|
@ -408,8 +427,7 @@ export class WorkspaceManager {
|
|||
const configuredEnvironment = this.readConfiguredEnvironment(serverConfig)
|
||||
if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath
|
||||
configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT
|
||||
configuredEnvironment.CODENOMAD_SERVICE_CONTENDERS = SERVICE_CONTENDER_FILE
|
||||
mkdirSync(SERVICE_STATE_ROOT, { recursive: true })
|
||||
prepareServiceState(SERVICE_CONTENDER_FILE)
|
||||
const launch = buildServiceLaunchSpec(resolvedBinaryPath, ["serve", "--service"], {
|
||||
env: { ...process.env, ...configuredEnvironment },
|
||||
propagateEnvKeys: Object.keys(configuredEnvironment),
|
||||
|
|
@ -418,10 +436,15 @@ export class WorkspaceManager {
|
|||
const ensureOptions: OpenCodeEnsureOptions = {
|
||||
file: SERVICE_REGISTRATION_FILE,
|
||||
command: launch.command,
|
||||
environment: {
|
||||
...configuredEnvironment,
|
||||
...(launch.env?.WSLENV ? { WSLENV: launch.env.WSLENV } : {}),
|
||||
},
|
||||
contenderFile: SERVICE_CONTENDER_FILE,
|
||||
leaseFile: SERVICE_LEASE_FILE,
|
||||
lockDirectory: SERVICE_STOP_LOCK,
|
||||
nativePid: launch.nativePid,
|
||||
wslDistro: launch.wslDistro,
|
||||
environment: launch.env,
|
||||
launcherRecordsPid: launch.launcherRecordsPid,
|
||||
windowsVerbatimArguments: launch.windowsVerbatimArguments,
|
||||
timeoutMs,
|
||||
}
|
||||
try {
|
||||
this.throwIfCancelled(record)
|
||||
|
|
@ -557,15 +580,23 @@ export class WorkspaceManager {
|
|||
async shutdown() {
|
||||
this.shuttingDown = true
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
const shutdownTimeoutMs = Math.max(1, this.options.shutdownTimeoutMs ?? 10000)
|
||||
const deadlineAt = Date.now() + shutdownTimeoutMs
|
||||
const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id))
|
||||
const results = stopTasks.length
|
||||
? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown")
|
||||
? await this.withTimeout(Promise.allSettled(stopTasks), shutdownTimeoutMs, "shutdown")
|
||||
: []
|
||||
const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
|
||||
if (this.workspaces.size === 0) {
|
||||
this.pendingWorkspaceCreations.clear()
|
||||
this.cancelledCreationRequests.clear()
|
||||
await this.sharedService.shutdown().catch((error) => stopFailures.push(error))
|
||||
const remaining = deadlineAt - Date.now()
|
||||
if (remaining <= 0) stopFailures.push(new WorkspaceCleanupTimeoutError("shared service shutdown", shutdownTimeoutMs))
|
||||
else await this.withTimeout(
|
||||
this.sharedService.shutdown({ timeoutMs: remaining }),
|
||||
remaining,
|
||||
"shared service shutdown",
|
||||
).catch((error) => stopFailures.push(error))
|
||||
} else if (!stopFailures.length) stopFailures.push(
|
||||
new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { Endpoint, Info } from "@opencode-ai/client/service"
|
||||
|
||||
import { OpenCodeSharedService } from "./opencode-service"
|
||||
import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service"
|
||||
import type { ProcessIdentity, ProcessIdentityProbe, ProcessNamespace } from "./process-identity"
|
||||
|
||||
describe("OpenCodeSharedService", () => {
|
||||
it("lazily ensures one authenticated service for concurrent callers", async () => {
|
||||
|
|
@ -26,7 +28,6 @@ describe("OpenCodeSharedService", () => {
|
|||
return { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "user", password: "pass" } }
|
||||
},
|
||||
headers: () => ({ authorization: "Basic token" }),
|
||||
stop: async () => undefined,
|
||||
makeClient: (options) => {
|
||||
makeCalls += 1
|
||||
assert.equal(options.baseUrl, "http://127.0.0.1:4321")
|
||||
|
|
@ -66,7 +67,6 @@ describe("OpenCodeSharedService", () => {
|
|||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "https://localhost:4321" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => client,
|
||||
})
|
||||
|
||||
|
|
@ -89,16 +89,22 @@ describe("OpenCodeSharedService", () => {
|
|||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "file:///tmp/opencode" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => { throw new Error("client should not be created") },
|
||||
})
|
||||
await assert.rejects(invalidEndpoint.endpoint(), /Unsupported OpenCode service protocol/)
|
||||
|
||||
const remoteEndpoint = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "http://192.0.2.1:4321" }),
|
||||
headers: () => undefined,
|
||||
makeClient: () => { throw new Error("client should not be created") },
|
||||
})
|
||||
await assert.rejects(remoteEndpoint.endpoint(), /must be loopback/)
|
||||
|
||||
const invalidLocation = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => ({ url: "http://localhost:4321" }),
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => ({ location: { get: async () => ({ directory: "/repo" }) } }) as unknown as OpenCodeClient,
|
||||
})
|
||||
await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/)
|
||||
|
|
@ -114,7 +120,6 @@ describe("OpenCodeSharedService", () => {
|
|||
return { url: "http://localhost:4321" }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
|
|
@ -134,7 +139,6 @@ describe("OpenCodeSharedService", () => {
|
|||
return endpoint
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => undefined,
|
||||
makeClient: () => ({
|
||||
location: { get: async () => {
|
||||
gets += 1
|
||||
|
|
@ -149,150 +153,562 @@ describe("OpenCodeSharedService", () => {
|
|||
assert.equal(ensures, 2)
|
||||
})
|
||||
|
||||
it("stops only when registration proves its contender won", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const previous = process.env.CODENOMAD_SERVICE_TEST
|
||||
const stopFiles: Array<string | undefined> = []
|
||||
const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" }
|
||||
const createService = (contenderPid: number) => new OpenCodeSharedService({
|
||||
discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }),
|
||||
ensure: async (options) => {
|
||||
assert.equal(process.env.CODENOMAD_SERVICE_TEST, "configured")
|
||||
options?.onStart?.("missing")
|
||||
await writeFile(contenders, `${contenderPid}\n`)
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async (options) => { stopFiles.push(options?.file) },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
it("persists native PID proof through transfer and shutdown reconstruction", async () => {
|
||||
const state = await serviceState("codenomad-service-peer-")
|
||||
let stops = 0
|
||||
const owner = createOwnedService(state, async () => { stops += 1; return true })
|
||||
const peer = createOwnedService(
|
||||
state,
|
||||
async () => { stops += 1; return true },
|
||||
false,
|
||||
(pid) => pid !== state.info.pid,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
try {
|
||||
const lost = createService(9999)
|
||||
await lost.endpoint({ file, environment: {
|
||||
CODENOMAD_SERVICE_TEST: "configured",
|
||||
CODENOMAD_SERVICE_CONTENDERS: contenders,
|
||||
} })
|
||||
assert.equal(process.env.CODENOMAD_SERVICE_TEST, previous)
|
||||
await lost.shutdown()
|
||||
assert.deepEqual(stopFiles, [])
|
||||
|
||||
const won = createService(info.pid)
|
||||
await won.endpoint({ file, environment: {
|
||||
CODENOMAD_SERVICE_TEST: "configured",
|
||||
CODENOMAD_SERVICE_CONTENDERS: contenders,
|
||||
} })
|
||||
await won.shutdown()
|
||||
assert.deepEqual(stopFiles, [file])
|
||||
await owner.endpoint(state.options("owner", true))
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, true)
|
||||
await peer.endpoint(state.options("peer", false))
|
||||
await owner.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
assert.equal(JSON.parse(await readFile(state.lease("peer"), "utf8")).service.nativePid, true)
|
||||
assert.equal((await peer.endpoint()).url, state.info.url)
|
||||
await peer.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODENOMAD_SERVICE_TEST
|
||||
else process.env.CODENOMAD_SERVICE_TEST = previous
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("retains possible ownership after discovery failure and retries shutdown", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-retry-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const info = { id: "instance-1", url: "http://localhost:4321", pid: 1234, password: "secret" }
|
||||
it("reclaims a bounded stale lifecycle lock after PID reuse", async () => {
|
||||
const state = await serviceState("codenomad-service-stale-lock-")
|
||||
const stalePid = 7654321
|
||||
await mkdir(state.lockDirectory)
|
||||
await writeFile(path.join(state.lockDirectory, "owner.json"), JSON.stringify({
|
||||
version: 1,
|
||||
identity: "stale-lock-owner",
|
||||
pid: stalePid,
|
||||
processIdentity: processIdentity(stalePid, "previous-process"),
|
||||
createdAt: 1,
|
||||
}))
|
||||
await Promise.all([
|
||||
utimes(path.join(state.lockDirectory, "owner.json"), 1, 1),
|
||||
utimes(state.lockDirectory, 1, 1),
|
||||
])
|
||||
const service = createOwnedService(state, async () => true, true, () => true)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), staleLockMs: 1 })
|
||||
await assert.rejects(access(state.lockDirectory))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("ages out ownerless, malformed, and legacy lifecycle locks but preserves fresh locks", async () => {
|
||||
for (const [name, owner] of [
|
||||
["ownerless", undefined],
|
||||
["malformed", "{"],
|
||||
["legacy", JSON.stringify({ version: 1, identity: "legacy", pid: 1234, createdAt: 1 })],
|
||||
] as const) {
|
||||
const state = await serviceState(`codenomad-service-${name}-lock-`)
|
||||
await mkdir(state.lockDirectory)
|
||||
if (owner) {
|
||||
const ownerFile = path.join(state.lockDirectory, "owner.json")
|
||||
await writeFile(ownerFile, owner)
|
||||
await utimes(ownerFile, 1, 1)
|
||||
}
|
||||
await utimes(state.lockDirectory, 1, 1)
|
||||
const service = createOwnedService(state, async () => true)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), staleLockMs: 10 })
|
||||
await assert.rejects(access(state.lockDirectory))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = await serviceState("codenomad-service-fresh-lock-")
|
||||
await mkdir(fresh.lockDirectory)
|
||||
const service = createOwnedService(fresh, async () => true)
|
||||
try {
|
||||
await assert.rejects(service.endpoint({ ...fresh.options("owner", true), timeoutMs: 10, staleLockMs: 60_000 }), /lifecycle lock/)
|
||||
await access(fresh.lockDirectory)
|
||||
} finally {
|
||||
await rm(fresh.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("prunes an identity-checked lease after PID reuse", async () => {
|
||||
const state = await serviceState("codenomad-service-stale-lease-")
|
||||
const stalePid = 7654321
|
||||
const staleLease = path.join(state.leases, "stale.json")
|
||||
await writeFile(staleLease, JSON.stringify({
|
||||
version: 1,
|
||||
identity: "stale-peer",
|
||||
pid: stalePid,
|
||||
processIdentity: processIdentity(stalePid, "previous-process"),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
}))
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return true }, true, () => true)
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
await assert.rejects(access(staleLease))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("quarantines stale registration after deterministic PID reuse without signaling it", async () => {
|
||||
const state = await serviceState("codenomad-service-stale-registration-")
|
||||
const reusedPid = state.info.pid
|
||||
await writeFile(state.lease("dead-owner"), JSON.stringify({
|
||||
version: 1,
|
||||
identity: "dead-owner",
|
||||
pid: 7654321,
|
||||
processIdentity: processIdentity(7654321, "dead-codenomad"),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
service: {
|
||||
info: state.info,
|
||||
endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } },
|
||||
registrationFile: state.file,
|
||||
nativePid: true,
|
||||
processIdentity: processIdentity(reusedPid, "old-service-process"),
|
||||
},
|
||||
}))
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
headers: () => undefined,
|
||||
isProcessAlive: () => true,
|
||||
getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(
|
||||
pid,
|
||||
pid === reusedPid ? "reused-service-process" : `identity-${pid}`,
|
||||
namespace,
|
||||
),
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
try {
|
||||
await assert.rejects(service.endpoint({
|
||||
...state.options("successor", false),
|
||||
command: [process.execPath, "-e", "process.exit(0)"],
|
||||
timeoutMs: 50,
|
||||
}), /exited before registration|timed out/)
|
||||
await assert.rejects(access(state.file))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("recovers a registration written after a predecessor launch intent", async () => {
|
||||
const state = await serviceState("codenomad-service-crash-window-")
|
||||
const deadPid = 7654321
|
||||
const launchCreatedAt = Date.now() - 1_000
|
||||
await writeFile(state.lease("dead-owner"), JSON.stringify({
|
||||
version: 1,
|
||||
identity: "dead-owner",
|
||||
pid: deadPid,
|
||||
processIdentity: processIdentity(deadPid, "dead-codenomad"),
|
||||
createdAt: launchCreatedAt,
|
||||
updatedAt: launchCreatedAt,
|
||||
state: "active",
|
||||
launch: {
|
||||
identity: "launch-before-crash",
|
||||
createdAt: launchCreatedAt,
|
||||
nativePid: true,
|
||||
contenderFile: state.contenders,
|
||||
},
|
||||
}))
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }),
|
||||
headers: () => undefined,
|
||||
isProcessAlive: (pid) => pid !== deadPid,
|
||||
getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
try {
|
||||
await service.endpoint(state.options("successor", false))
|
||||
const lease = JSON.parse(await readFile(state.lease("successor"), "utf8"))
|
||||
assert.deepEqual(lease.service.processIdentity, processIdentity(state.info.pid))
|
||||
assert.equal(lease.service.info.pid, state.info.pid)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("prunes old invalid lease artifacts while preserving fresh writes", async () => {
|
||||
const state = await serviceState("codenomad-service-invalid-leases-")
|
||||
const oldLease = state.lease("invalid")
|
||||
const legacyLease = state.lease("legacy")
|
||||
const oldTemporary = `${state.lease("peer")}.peer-id.tmp`
|
||||
const freshLease = state.lease("fresh")
|
||||
await writeFile(oldLease, "{")
|
||||
await writeFile(legacyLease, JSON.stringify({
|
||||
version: 1,
|
||||
identity: "legacy",
|
||||
pid: 1234,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
}))
|
||||
await writeFile(oldTemporary, "partial")
|
||||
await writeFile(freshLease, "{")
|
||||
await Promise.all([utimes(oldLease, 1, 1), utimes(legacyLease, 1, 1), utimes(oldTemporary, 1, 1)])
|
||||
const service = createOwnedService(state, async () => true)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), staleLockMs: 60_000 })
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 20 }), /invalid identity metadata/)
|
||||
await Promise.all([
|
||||
assert.rejects(access(oldLease)),
|
||||
assert.rejects(access(legacyLease)),
|
||||
assert.rejects(access(oldTemporary)),
|
||||
])
|
||||
await access(freshLease)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("inherits proven ownership from a dead owner lease", async () => {
|
||||
const state = await serviceState("codenomad-service-dead-owner-")
|
||||
const deadPid = 7654321
|
||||
await writeFile(state.lease("dead-owner"), JSON.stringify({
|
||||
version: 1,
|
||||
identity: "dead-owner",
|
||||
pid: deadPid,
|
||||
processIdentity: processIdentity(deadPid),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
state: "active",
|
||||
service: {
|
||||
info: state.info,
|
||||
endpoint: { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } },
|
||||
registrationFile: state.file,
|
||||
nativePid: true,
|
||||
},
|
||||
}))
|
||||
let stops = 0
|
||||
const peer = createOwnedService(state, async () => { stops += 1; return true }, false, (pid) => pid !== deadPid)
|
||||
try {
|
||||
await peer.endpoint(state.options("peer", false))
|
||||
await peer.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
await assert.rejects(access(state.lease("dead-owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("replaces stale in-memory ownership with a newer transferred service proof", async () => {
|
||||
const state = await serviceState("codenomad-service-replacement-owner-")
|
||||
const requested: string[] = []
|
||||
const staleOwner = createOwnedService(state, async (info) => { requested.push(info.id!); return true })
|
||||
const replacementOwner = createOwnedService(state, async () => { throw new Error("peer must not stop while an owner remains") })
|
||||
try {
|
||||
await staleOwner.endpoint(state.options("stale-owner", true))
|
||||
Object.assign(state.info, { id: "instance-2", pid: 5678, url: "http://127.0.0.1:5678", password: "replacement-secret" })
|
||||
await writeFile(state.file, JSON.stringify(state.info))
|
||||
await writeFile(state.contenders, `${state.info.pid}\n`)
|
||||
await replacementOwner.endpoint(state.options("replacement-owner", true))
|
||||
|
||||
await replacementOwner.shutdown()
|
||||
assert.deepEqual(requested, [])
|
||||
await staleOwner.shutdown()
|
||||
assert.deepEqual(requested, ["instance-2"])
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("stops the proven endpoint without following a registration swap", async () => {
|
||||
const state = await serviceState("codenomad-service-swap-")
|
||||
const replacement = { ...state.info, id: "replacement", pid: 9999, url: "http://127.0.0.1:9999" }
|
||||
let requestedId: string | undefined
|
||||
const service = createOwnedService(state, async (info: Info, endpoint: Endpoint) => {
|
||||
await writeFile(state.file, JSON.stringify(replacement))
|
||||
requestedId = info.id
|
||||
assert.equal(endpoint.url, state.info.url)
|
||||
return true
|
||||
})
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(requestedId, state.info.id)
|
||||
assert.deepEqual(JSON.parse(await readFile(state.file, "utf8")), replacement)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("never owns a symlinked registration", { skip: process.platform === "win32" }, async () => {
|
||||
const state = await serviceState("codenomad-service-link-")
|
||||
const target = path.join(state.root, "target.json")
|
||||
await writeFile(target, JSON.stringify(state.info))
|
||||
await rm(state.file)
|
||||
await symlink(target, state.file)
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return true })
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a wrapper launch without a proven service PID before connecting", async () => {
|
||||
const state = await serviceState("codenomad-service-wrapper-")
|
||||
let launches = 0
|
||||
let discoveries = 0
|
||||
let stops = 0
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => ({ url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }),
|
||||
ensure: async (options) => {
|
||||
options?.onStart?.("missing")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await writeFile(contenders, `${info.pid}\n`)
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
discover: async () => { discoveries += 1; return undefined },
|
||||
ensure: async () => { launches += 1; return { url: state.info.url } },
|
||||
headers: () => undefined,
|
||||
stop: async () => { stops += 1 },
|
||||
requestStop: async () => { stops += 1; return true },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
|
||||
try {
|
||||
await service.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: contenders } })
|
||||
await rm(file)
|
||||
await assert.rejects(service.endpoint({
|
||||
...state.options("wrapper", true),
|
||||
contenderFile: undefined,
|
||||
nativePid: false,
|
||||
}), /cannot prove the service PID/)
|
||||
await service.shutdown()
|
||||
assert.equal(launches, 0)
|
||||
assert.equal(discoveries, 0)
|
||||
assert.equal(stops, 0)
|
||||
|
||||
await writeFile(file, JSON.stringify({ ...info, id: "replacement", pid: 5678 }))
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 0)
|
||||
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await Promise.all([service.shutdown(), service.shutdown()])
|
||||
assert.equal(stops, 1)
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("lets only the CodeNomad whose contender won stop a concurrent service", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-service-election-"))
|
||||
const file = path.join(root, "service.json")
|
||||
const firstContenders = path.join(root, "first.txt")
|
||||
const secondContenders = path.join(root, "second.txt")
|
||||
const info = { id: "winner", url: "http://localhost:4321", pid: 2222, password: "secret" }
|
||||
const starts = deferred<void>()
|
||||
let ready = false
|
||||
it("bounds a stalled service stop", async () => {
|
||||
const state = await serviceState("codenomad-service-stop-timeout-")
|
||||
let stops = 0
|
||||
const createService = (contenderFile: string, pid: number) => new OpenCodeSharedService({
|
||||
discover: async () => ready
|
||||
? { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
: undefined,
|
||||
ensure: async (options) => {
|
||||
options?.onStart?.("missing")
|
||||
await writeFile(contenderFile, `${pid}\n`)
|
||||
await starts.promise
|
||||
return { url: info.url, auth: { type: "basic", username: "opencode", password: info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
stop: async () => { stops += 1 },
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
const first = createService(firstContenders, info.pid)
|
||||
const second = createService(secondContenders, 3333)
|
||||
|
||||
const service = createOwnedService(
|
||||
state,
|
||||
async () => { stops += 1; return new Promise<boolean>(() => undefined) },
|
||||
true,
|
||||
undefined,
|
||||
async () => false,
|
||||
)
|
||||
try {
|
||||
const connections = [
|
||||
first.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: firstContenders } }),
|
||||
second.endpoint({ file, environment: { CODENOMAD_SERVICE_CONTENDERS: secondContenders } }),
|
||||
]
|
||||
await Promise.all([readWhenPresent(firstContenders), readWhenPresent(secondContenders)])
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
ready = true
|
||||
starts.resolve()
|
||||
await Promise.all(connections)
|
||||
|
||||
await Promise.all([first.shutdown(), second.shutdown()])
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 10 }), /stop timed out/)
|
||||
const lease = JSON.parse(await readFile(state.lease("owner"), "utf8"))
|
||||
assert.equal(lease.state, "stopping")
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 10 }), /uncertain outcome/)
|
||||
assert.equal(stops, 1)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("retains ownership until an accepted stop actually completes", async () => {
|
||||
const state = await serviceState("codenomad-service-stop-completion-")
|
||||
let finishStop!: () => void
|
||||
let markAccepted!: () => void
|
||||
const accepted = new Promise<void>((resolve) => { markAccepted = resolve })
|
||||
const completion = new Promise<boolean>((resolve) => { finishStop = () => resolve(true) })
|
||||
const service = createOwnedService(state, async () => { markAccepted(); return true }, true, undefined, async () => completion)
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
const shutdown = service.shutdown({ timeoutMs: 100 })
|
||||
await accepted
|
||||
await access(state.lease("owner"))
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping")
|
||||
finishStop()
|
||||
await shutdown
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => {
|
||||
const state = await serviceState("codenomad-service-wsl-stop-")
|
||||
let healthChecks = 0
|
||||
let wslPidExists = true
|
||||
const server = (await import("node:http")).createServer((_request, response) => {
|
||||
healthChecks += 1
|
||||
if (healthChecks <= 2) {
|
||||
response.destroy()
|
||||
return
|
||||
}
|
||||
response.setHeader("content-type", "application/json")
|
||||
response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
state.info.url = `http://127.0.0.1:${address.port}`
|
||||
await writeFile(state.file, JSON.stringify(state.info))
|
||||
const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const
|
||||
const service = createOwnedService(
|
||||
state,
|
||||
async () => true,
|
||||
true,
|
||||
() => true,
|
||||
undefined,
|
||||
true,
|
||||
async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
async (pid, _timeoutMs, namespace) => wslPidExists
|
||||
? { status: "found", identity: processIdentity(pid, undefined, namespace) }
|
||||
: { status: "missing" },
|
||||
)
|
||||
try {
|
||||
await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" })
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false)
|
||||
assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace)
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/)
|
||||
assert.ok(healthChecks > 2)
|
||||
assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping")
|
||||
wslPidExists = false
|
||||
await service.shutdown({ timeoutMs: 100 })
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("reports a failed stop and keeps an active retryable lease", async () => {
|
||||
const state = await serviceState("codenomad-service-stop-failure-")
|
||||
let stops = 0
|
||||
const service = createOwnedService(state, async () => { stops += 1; return stops > 1 })
|
||||
try {
|
||||
await service.endpoint(state.options("owner", true))
|
||||
await assert.rejects(service.shutdown(), /failed the stop request/)
|
||||
const lease = JSON.parse(await readFile(state.lease("owner"), "utf8"))
|
||||
assert.equal(lease.state, "active")
|
||||
await service.shutdown()
|
||||
assert.equal(stops, 2)
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("bounds a stalled ensure", async () => {
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: async () => new Promise<never>(() => undefined),
|
||||
headers: () => undefined,
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
await assert.rejects(service.endpoint({ timeoutMs: 10 }), /timed out after 10ms/)
|
||||
})
|
||||
|
||||
it("reconciles a late uncancellable ensure during shutdown", async () => {
|
||||
const state = await serviceState("codenomad-service-late-ensure-")
|
||||
let finishEnsure!: () => void
|
||||
let finishStop!: () => void
|
||||
let markStopStarted!: () => void
|
||||
const stopStarted = new Promise<void>((resolve) => { markStopStarted = resolve })
|
||||
const stopCompletion = new Promise<boolean>((resolve) => { finishStop = () => resolve(true) })
|
||||
let stops = 0
|
||||
const service = new OpenCodeSharedService({
|
||||
discover: async () => undefined,
|
||||
ensure: (options) => new Promise<Endpoint>((resolve) => {
|
||||
options?.onStart?.("missing")
|
||||
finishEnsure = () => resolve({
|
||||
url: state.info.url,
|
||||
auth: { type: "basic", username: "opencode", password: state.info.password },
|
||||
})
|
||||
}),
|
||||
headers: () => undefined,
|
||||
requestStop: async () => { stops += 1; markStopStarted(); return true },
|
||||
waitForStop: async () => stopCompletion,
|
||||
getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
try {
|
||||
await assert.rejects(service.endpoint({ ...state.options("owner", true), timeoutMs: 10 }), /ensure timed out/)
|
||||
await access(state.lease("owner"))
|
||||
await assert.rejects(service.shutdown({ timeoutMs: 10 }), /launch reconciliation timed out/)
|
||||
finishEnsure()
|
||||
await stopStarted
|
||||
const reconciliation = service.shutdown({ timeoutMs: 100 })
|
||||
finishStop()
|
||||
await reconciliation
|
||||
assert.equal(stops, 1)
|
||||
await assert.rejects(access(state.lease("owner")))
|
||||
} finally {
|
||||
await rm(state.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
||||
return { promise, resolve }
|
||||
async function serviceState(prefix: string) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const file = path.join(root, "service.json")
|
||||
const contenders = path.join(root, "contenders.txt")
|
||||
const leases = path.join(root, "leases")
|
||||
const lockDirectory = path.join(root, "stop.lock")
|
||||
const info = { id: "instance-1", url: "http://127.0.0.1:4321", pid: 1234, password: "secret" }
|
||||
await mkdir(leases)
|
||||
await writeFile(file, JSON.stringify(info))
|
||||
await writeFile(contenders, `${info.pid}\n`)
|
||||
return {
|
||||
root, file, contenders, leases, lockDirectory, info,
|
||||
lease(name: string) { return path.join(leases, `${name}.json`) },
|
||||
options(name: string, started: boolean): OpenCodeEnsureOptions {
|
||||
return {
|
||||
file,
|
||||
contenderFile: contenders,
|
||||
leaseFile: path.join(leases, `${name}.json`),
|
||||
lockDirectory,
|
||||
onStart: started ? () => undefined : undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readWhenPresent(file: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
await access(file)
|
||||
return
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
function createOwnedService(
|
||||
state: Awaited<ReturnType<typeof serviceState>>,
|
||||
requestStop: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
|
||||
announceStart = true,
|
||||
isProcessAlive?: (pid: number) => boolean,
|
||||
waitForStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise<boolean>,
|
||||
useDefaultWaitForStop = false,
|
||||
getProcessIdentity: (
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace?: ProcessNamespace,
|
||||
) => Promise<ProcessIdentity | undefined> = async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace),
|
||||
probeProcessIdentity?: (
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace?: ProcessNamespace,
|
||||
) => Promise<ProcessIdentityProbe>,
|
||||
) {
|
||||
return new OpenCodeSharedService({
|
||||
discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }),
|
||||
ensure: async (options) => {
|
||||
if (announceStart) options?.onStart?.("missing")
|
||||
return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }
|
||||
},
|
||||
headers: () => undefined,
|
||||
requestStop,
|
||||
waitForStop: useDefaultWaitForStop ? undefined : waitForStop ?? (async () => true),
|
||||
isProcessAlive,
|
||||
getProcessIdentity,
|
||||
probeProcessIdentity,
|
||||
makeClient: () => ({} as OpenCodeClient),
|
||||
})
|
||||
}
|
||||
|
||||
function processIdentity(
|
||||
pid: number,
|
||||
start = `identity-${pid}`,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
): ProcessIdentity {
|
||||
return { namespace, pid, start }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
36
packages/server/src/workspaces/process-identity.test.ts
Normal file
36
packages/server/src/workspaces/process-identity.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
|
||||
import { probeProcessStartIdentity } from "./process-identity"
|
||||
|
||||
test("probes a service PID inside its WSL distro instead of the coincidental Windows PID", async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = []
|
||||
const probe = await probeProcessStartIdentity(4242, 100, { kind: "wsl", distro: "Ubuntu" }, async (command, args) => {
|
||||
calls.push({ command, args })
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "4242 (opencode) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 98765 20\nlinux-boot-id\n",
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(calls[0]?.command, "wsl.exe")
|
||||
assert.deepEqual(calls[0]?.args.slice(0, 2), ["--distribution", "Ubuntu"])
|
||||
assert.deepEqual(probe, {
|
||||
status: "found",
|
||||
identity: { namespace: { kind: "wsl", distro: "Ubuntu" }, pid: 4242, start: "linux-boot-id:98765" },
|
||||
})
|
||||
})
|
||||
|
||||
test("distinguishes a missing WSL PID from an unverified probe", async () => {
|
||||
const missing = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
|
||||
code: 3,
|
||||
stdout: "",
|
||||
}))
|
||||
const unknown = await probeProcessStartIdentity(7, 100, { kind: "wsl", distro: "Ubuntu" }, async () => ({
|
||||
code: null,
|
||||
stdout: "",
|
||||
}))
|
||||
|
||||
assert.deepEqual(missing, { status: "missing" })
|
||||
assert.deepEqual(unknown, { status: "unknown" })
|
||||
})
|
||||
102
packages/server/src/workspaces/process-identity.ts
Normal file
102
packages/server/src/workspaces/process-identity.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { execFile } from "node:child_process"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
export type ProcessNamespace = { kind: "host" } | { kind: "wsl"; distro: string }
|
||||
|
||||
export interface ProcessIdentity {
|
||||
namespace: ProcessNamespace
|
||||
pid: number
|
||||
start: string
|
||||
}
|
||||
|
||||
export type ProcessIdentityProbe =
|
||||
| { status: "found"; identity: ProcessIdentity }
|
||||
| { status: "missing" }
|
||||
| { status: "unknown" }
|
||||
|
||||
export type ProcessIdentityRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<{ code: number | null; stdout: string }>
|
||||
|
||||
const runCommand: ProcessIdentityRunner = (command, args, timeoutMs) => new Promise((resolve) => {
|
||||
execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
|
||||
resolve({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout })
|
||||
})
|
||||
})
|
||||
|
||||
export async function probeProcessStartIdentity(
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
runner: ProcessIdentityRunner = runCommand,
|
||||
): Promise<ProcessIdentityProbe> {
|
||||
if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return { status: "unknown" }
|
||||
if (namespace.kind === "wsl") {
|
||||
const result = await runner("wsl.exe", [
|
||||
"--distribution", namespace.distro,
|
||||
"--exec", "sh", "-c",
|
||||
'test -r "/proc/$1/stat" || exit 3; cat "/proc/$1/stat"; cat /proc/sys/kernel/random/boot_id',
|
||||
"codenomad-process-probe", String(pid),
|
||||
], timeoutMs).catch(() => ({ code: null, stdout: "" }))
|
||||
if (result.code === 3) return { status: "missing" }
|
||||
const [stat, bootId] = result.stdout.trim().split(/\r?\n/)
|
||||
const start = result.code === 0 && stat && bootId ? linuxStart(stat, bootId) : undefined
|
||||
return start
|
||||
? { status: "found", identity: { namespace, pid, start } }
|
||||
: { status: "unknown" }
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const signal = AbortSignal.timeout(timeoutMs)
|
||||
const stat = await readFile(`/proc/${pid}/stat`, { encoding: "utf8", signal })
|
||||
const bootId = (await readFile("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim()
|
||||
const start = linuxStart(stat, bootId)
|
||||
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return commandProbe(runner, "ps", ["-p", String(pid), "-o", "lstart="], namespace, pid, timeoutMs)
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return commandProbe(runner, "powershell.exe", [
|
||||
"-NoProfile", "-NonInteractive", "-Command",
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop; if (!$p) { exit 3 }; $p.CreationDate.ToUniversalTime().Ticks`,
|
||||
], namespace, pid, timeoutMs)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }
|
||||
}
|
||||
return { status: "unknown" }
|
||||
}
|
||||
|
||||
export async function getProcessStartIdentity(
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
namespace: ProcessNamespace = { kind: "host" },
|
||||
runner?: ProcessIdentityRunner,
|
||||
): Promise<ProcessIdentity | undefined> {
|
||||
const probe = await probeProcessStartIdentity(pid, timeoutMs, namespace, runner)
|
||||
return probe.status === "found" ? probe.identity : undefined
|
||||
}
|
||||
|
||||
function linuxStart(stat: string, bootId: string): string | undefined {
|
||||
const commandEnd = stat.lastIndexOf(")")
|
||||
const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19]
|
||||
return startTicks && bootId ? `${bootId}:${startTicks}` : undefined
|
||||
}
|
||||
|
||||
async function commandProbe(
|
||||
runner: ProcessIdentityRunner,
|
||||
command: string,
|
||||
args: string[],
|
||||
namespace: ProcessNamespace,
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
): Promise<ProcessIdentityProbe> {
|
||||
const result = await runner(command, args, timeoutMs).catch(() => ({ code: null, stdout: "" }))
|
||||
if (result.code === 3) return { status: "missing" }
|
||||
const start = result.code === 0 ? result.stdout.trim() : ""
|
||||
return start ? { status: "found", identity: { namespace, pid, start } } : { status: "unknown" }
|
||||
}
|
||||
106
packages/server/src/workspaces/service-state.ts
Normal file
106
packages/server/src/workspaces/service-state.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { constants, closeSync, chmodSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs"
|
||||
import { open } from "node:fs/promises"
|
||||
import { isIP } from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { Info } from "@opencode-ai/client/service"
|
||||
|
||||
const CODENOMAD_HOME = path.join(os.homedir(), ".codenomad")
|
||||
const CODENOMAD_STATE = path.join(CODENOMAD_HOME, "state")
|
||||
export const SERVICE_STATE_ROOT = path.join(CODENOMAD_STATE, "opencode-v2")
|
||||
export const SERVICE_REGISTRATION_FILE = path.join(SERVICE_STATE_ROOT, "opencode", "service.json")
|
||||
export const SERVICE_LEASE_DIRECTORY = path.join(SERVICE_STATE_ROOT, "leases")
|
||||
export const SERVICE_STOP_LOCK = path.join(SERVICE_STATE_ROOT, "stop.lock")
|
||||
|
||||
const preparedFiles = new Set<string>()
|
||||
|
||||
export function prepareServiceState(contenderFile: string): void {
|
||||
ensurePrivateDirectory(CODENOMAD_HOME)
|
||||
ensurePrivateDirectory(CODENOMAD_STATE)
|
||||
ensurePrivateDirectory(SERVICE_STATE_ROOT)
|
||||
ensurePrivateDirectory(path.dirname(SERVICE_REGISTRATION_FILE))
|
||||
ensurePrivateDirectory(SERVICE_LEASE_DIRECTORY)
|
||||
validateRegistrationFile(SERVICE_REGISTRATION_FILE)
|
||||
if (preparedFiles.has(contenderFile)) return
|
||||
closeSync(openSync(contenderFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600))
|
||||
preparedFiles.add(contenderFile)
|
||||
}
|
||||
|
||||
export async function readSecureServiceInfo(file: string | undefined): Promise<Info | undefined> {
|
||||
if (!file) return undefined
|
||||
let handle
|
||||
try {
|
||||
const before = lstatSync(file)
|
||||
if (!before.isFile() || before.isSymbolicLink()) return undefined
|
||||
const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW
|
||||
handle = await open(file, flags)
|
||||
const after = await handle.stat()
|
||||
if (process.platform !== "win32" && (before.dev !== after.dev || before.ino !== after.ino)) return undefined
|
||||
return parseInfo(await handle.readFile("utf8"))
|
||||
} catch {
|
||||
return undefined
|
||||
} finally {
|
||||
await handle?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLoopbackServiceUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Unsupported OpenCode service protocol: ${url.protocol}`)
|
||||
}
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||
const ipVersion = isIP(hostname)
|
||||
const loopback = hostname === "localhost"
|
||||
|| (ipVersion === 4 && hostname.startsWith("127."))
|
||||
|| (ipVersion === 6 && (hostname === "::1" || hostname.startsWith("::ffff:127.")))
|
||||
if (!loopback) throw new Error(`OpenCode service endpoint must be loopback: ${url.hostname}`)
|
||||
return url
|
||||
}
|
||||
|
||||
function ensurePrivateDirectory(directory: string): void {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
const stat = lstatSync(directory)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service state directory: ${directory}`)
|
||||
if (process.platform === "win32") return
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`OpenCode service state directory is owned by another user: ${directory}`)
|
||||
}
|
||||
if ((stat.mode & 0o077) !== 0) chmodSync(directory, 0o700)
|
||||
}
|
||||
|
||||
function validateRegistrationFile(file: string): void {
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(file)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
||||
throw error
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe OpenCode service registration file: ${file}`)
|
||||
const text = readFileSync(file, "utf8")
|
||||
let value: unknown
|
||||
try { value = JSON.parse(text) } catch { return }
|
||||
if (typeof value === "object" && value !== null && "url" in value && typeof value.url === "string") {
|
||||
assertLoopbackServiceUrl(value.url)
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfo(text: string): Info | undefined {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return undefined
|
||||
if (!("url" in value) || typeof value.url !== "string") return undefined
|
||||
if (!("id" in value) || typeof value.id !== "string" || !value.id) return undefined
|
||||
if (!("pid" in value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined
|
||||
try {
|
||||
assertLoopbackServiceUrl(value.url)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return value as Info
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawnSync } from "child_process"
|
||||
import { statSync } from "fs"
|
||||
import { readFileSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
|
|
@ -41,6 +41,10 @@ export interface SpawnSpec {
|
|||
export interface ServiceLaunchSpec {
|
||||
command: string[]
|
||||
env?: NodeJS.ProcessEnv
|
||||
nativePid: boolean
|
||||
wslDistro?: string
|
||||
launcherRecordsPid?: boolean
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
|
|
@ -93,7 +97,8 @@ export function buildWindowsSpawnSpec(binaryPath: string, args: string[], option
|
|||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
const resolvedBinaryPath = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath
|
||||
const resolvedCommand = resolveBareWindowsCommand(binaryPath, options) ?? binaryPath
|
||||
const resolvedBinaryPath = resolveWindowsNpmExecutable(resolvedCommand) ?? resolvedCommand
|
||||
const extension = path.win32.extname(resolvedBinaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
|
|
@ -157,37 +162,37 @@ export function buildServiceLaunchSpec(
|
|||
options: BuildSpawnSpecOptions = {},
|
||||
): ServiceLaunchSpec {
|
||||
const spec = buildSpawnSpec(binaryPath, args, options)
|
||||
if (spec.processKind === "wsl") {
|
||||
return { command: [spec.command, ...spec.args], env: spec.env }
|
||||
const direct = spec.processKind === "posix" || spec.processKind === "windows-direct"
|
||||
if (direct && options.contenderFile) {
|
||||
const launcher = [
|
||||
'const { spawn } = require("node:child_process")',
|
||||
'const { appendFileSync } = require("node:fs")',
|
||||
'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: process.argv[4] === "true" })',
|
||||
'child.once("error", (error) => { console.error(error); process.exitCode = 1 })',
|
||||
'if (child.pid) { appendFileSync(process.argv[3], `${child.pid}\\n`); child.unref() }',
|
||||
].join(";")
|
||||
return {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
launcher,
|
||||
spec.command,
|
||||
JSON.stringify(spec.args),
|
||||
options.contenderFile,
|
||||
String(Boolean(spec.options.windowsVerbatimArguments)),
|
||||
],
|
||||
env: spec.env,
|
||||
nativePid: true,
|
||||
launcherRecordsPid: true,
|
||||
}
|
||||
}
|
||||
const contenderFile = spec.processKind === "posix" || spec.processKind === "windows-direct"
|
||||
? options.contenderFile
|
||||
: undefined
|
||||
if (!spec.options.windowsVerbatimArguments && !contenderFile) {
|
||||
return { command: [spec.command, ...spec.args], env: spec.env }
|
||||
}
|
||||
|
||||
// Service.ensure cannot pass spawn options or expose contender PIDs. A Node
|
||||
// trampoline supplies both for commands whose child PID is the service PID.
|
||||
const launcher = [
|
||||
'const { spawn } = require("node:child_process")',
|
||||
'const { appendFileSync } = require("node:fs")',
|
||||
'const child = spawn(process.argv[1], JSON.parse(process.argv[2]), { stdio: "inherit", windowsVerbatimArguments: process.argv[4] === "true" })',
|
||||
'if (process.argv[3]) appendFileSync(process.argv[3], `${child.pid}\\n`)',
|
||||
'child.once("error", (error) => { console.error(error); process.exit(1) })',
|
||||
'child.once("exit", (code) => process.exit(code ?? 1))',
|
||||
].join(";")
|
||||
return {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
launcher,
|
||||
spec.command,
|
||||
JSON.stringify(spec.args),
|
||||
contenderFile ?? "",
|
||||
String(Boolean(spec.options.windowsVerbatimArguments)),
|
||||
],
|
||||
command: [spec.command, ...spec.args],
|
||||
env: spec.env,
|
||||
nativePid: direct,
|
||||
wslDistro: spec.wsl?.distro,
|
||||
launcherRecordsPid: spec.processKind === "wsl" && Boolean(options.contenderFile),
|
||||
windowsVerbatimArguments: spec.options.windowsVerbatimArguments,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +379,20 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined,
|
|||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function resolveWindowsNpmExecutable(command: string): string | null {
|
||||
if (!WINDOWS_CMD_EXTENSIONS.has(path.win32.extname(command).toLowerCase())) return null
|
||||
try {
|
||||
const script = readFileSync(command, "utf8")
|
||||
if (script.length > 64 * 1024) return null
|
||||
const match = script.match(/["'](?:%~dp0|%dp0%)[\\/]([^"'\r\n]+\.exe)["']\s+%\*/i)
|
||||
if (!match?.[1]) return null
|
||||
const executable = path.win32.resolve(path.win32.dirname(command), match[1])
|
||||
return statSync(executable).isFile() ? executable : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { realpath } from "fs/promises"
|
||||
import path from "node:path"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
|
||||
|
||||
|
|
@ -97,3 +98,22 @@ export async function resolveWorktreeSlugForDirectory(params: {
|
|||
})
|
||||
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
|
||||
}
|
||||
|
||||
export async function isPathOwnedByWorktree(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
candidate: string
|
||||
logger?: LogLike
|
||||
}): Promise<boolean> {
|
||||
let target: string
|
||||
try {
|
||||
target = await realpath(params.candidate)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const cached = await getCachedWorktrees(params)
|
||||
return cached.worktrees.some((worktree) => {
|
||||
const relative = path.relative(worktree.normalizedDirectory, target)
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
52
packages/tauri-app/Cargo.lock
generated
52
packages/tauri-app/Cargo.lock
generated
|
|
@ -1014,15 +1014,6 @@ version = "1.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
|
|
@ -1226,7 +1217,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1649,25 +1639,6 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
|
|
@ -1793,7 +1764,6 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
|
|
@ -3403,11 +3373,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
@ -3416,7 +3382,6 @@ dependencies = [
|
|||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -3428,14 +3393,12 @@ dependencies = [
|
|||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
|
@ -3470,7 +3433,7 @@ dependencies = [
|
|||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
|
@ -5231,19 +5194,6 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ serde_json = "1"
|
|||
serde_yaml = "0.9"
|
||||
base64 = "0.22"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
regex = "1"
|
||||
parking_lot = "0.12"
|
||||
anyhow = "1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use crate::desktop_event_transport::DesktopEventStreamConfig;
|
||||
use crate::managed_node::resolve_bundled_node_binary;
|
||||
use dirs::home_dir;
|
||||
use parking_lot::Mutex;
|
||||
|
|
@ -565,15 +564,6 @@ fn generate_auth_cookie_name() -> String {
|
|||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
fn generate_transport_connection_id() -> String {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let tid = std::thread::current().id();
|
||||
format!("tauri-{}-{:?}", ts, tid)
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -731,8 +721,6 @@ pub struct CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
session_cookie: Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: Arc<Mutex<Option<String>>>,
|
||||
lifecycle: Arc<Mutex<()>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
|
@ -745,8 +733,6 @@ impl CliProcessManager {
|
|||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
session_cookie: Arc::new(Mutex::new(None)),
|
||||
auth_cookie_name: Arc::new(Mutex::new(None)),
|
||||
lifecycle: Arc::new(Mutex::new(())),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
|
|
@ -758,8 +744,6 @@ impl CliProcessManager {
|
|||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop_tracked_child()?;
|
||||
*self.bootstrap_token.lock() = None;
|
||||
*self.session_cookie.lock() = None;
|
||||
*self.auth_cookie_name.lock() = None;
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
|
|
@ -855,7 +839,6 @@ impl CliProcessManager {
|
|||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
*self.session_cookie.lock() = None;
|
||||
}
|
||||
|
||||
fn publish_error(&self, app: &AppHandle, generation: u64, message: String) {
|
||||
|
|
@ -874,26 +857,6 @@ impl CliProcessManager {
|
|||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
pub fn desktop_event_stream_config(&self) -> Option<DesktopEventStreamConfig> {
|
||||
let base_url = self.status.lock().url.clone()?;
|
||||
let events_url = format!("{}/api/events", base_url.trim_end_matches('/'));
|
||||
let client_id = format!("tauri-{}", std::process::id());
|
||||
let cookie_name = self
|
||||
.auth_cookie_name
|
||||
.lock()
|
||||
.clone()
|
||||
.unwrap_or_else(|| SESSION_COOKIE_NAME_PREFIX.to_string());
|
||||
|
||||
Some(DesktopEventStreamConfig {
|
||||
base_url,
|
||||
events_url,
|
||||
client_id,
|
||||
connection_id: generate_transport_connection_id(),
|
||||
cookie_name,
|
||||
session_cookie: self.session_cookie.lock().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
manager: CliProcessManager,
|
||||
app: AppHandle,
|
||||
|
|
@ -1037,7 +1000,6 @@ impl CliProcessManager {
|
|||
let stdout = child.stdout.take().map(BufReader::new);
|
||||
let stderr = child.stderr.take().map(BufReader::new);
|
||||
debug_assert!(manager.child.lock().is_none());
|
||||
*manager.auth_cookie_name.lock() = Some(auth_cookie_name.as_str().to_string());
|
||||
manager.status.lock().pid = Some(pid);
|
||||
*manager.child.lock() = Some(child);
|
||||
#[cfg(windows)]
|
||||
|
|
@ -1317,9 +1279,6 @@ impl CliProcessManager {
|
|||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(manager, generation, app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
manager.with_current_generation(generation, || {
|
||||
*manager.session_cookie.lock() = Some(session_id.clone());
|
||||
});
|
||||
navigate_main(manager, generation, app, &base_url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,477 +0,0 @@
|
|||
use parking_lot::Mutex;
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
|
||||
mod assembler;
|
||||
mod stream;
|
||||
mod transport;
|
||||
|
||||
use stream::*;
|
||||
use transport::*;
|
||||
|
||||
const EVENT_BATCH_NAME: &str = "desktop:event-batch";
|
||||
const EVENT_STATUS_NAME: &str = "desktop:event-stream-status";
|
||||
const FLUSH_INTERVAL_MS: u64 = 16;
|
||||
const DELTA_STREAM_WINDOW_MS: u64 = 48;
|
||||
const MAX_BATCH_EVENTS: usize = 256;
|
||||
const DEFAULT_RECONNECT_INITIAL_DELAY_MS: u64 = 1_000;
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS: u64 = 10_000;
|
||||
const DEFAULT_RECONNECT_MULTIPLIER: f64 = 2.0;
|
||||
const STREAM_CONNECT_TIMEOUT_MS: u64 = 5_000;
|
||||
const STREAM_TCP_KEEPALIVE_MS: u64 = 30_000;
|
||||
const STREAM_STALL_TIMEOUT_MS: u64 = 30_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DesktopEventStreamConfig {
|
||||
pub base_url: String,
|
||||
pub events_url: String,
|
||||
pub client_id: String,
|
||||
pub connection_id: String,
|
||||
pub cookie_name: String,
|
||||
pub session_cookie: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DesktopEventsStartRequest {
|
||||
pub reconnect: Option<DesktopEventReconnectPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DesktopEventReconnectPolicy {
|
||||
pub initial_delay_ms: Option<u64>,
|
||||
pub max_delay_ms: Option<u64>,
|
||||
pub multiplier: Option<f64>,
|
||||
pub max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopEventsStartResult {
|
||||
pub started: bool,
|
||||
pub generation: Option<u64>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct ResolvedDesktopEventReconnectPolicy {
|
||||
initial_delay_ms: u64,
|
||||
max_delay_ms: u64,
|
||||
multiplier: f64,
|
||||
max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
impl ResolvedDesktopEventReconnectPolicy {
|
||||
fn resolve(policy: Option<&DesktopEventReconnectPolicy>) -> Self {
|
||||
let initial_delay_ms = policy
|
||||
.and_then(|value| value.initial_delay_ms)
|
||||
.unwrap_or(DEFAULT_RECONNECT_INITIAL_DELAY_MS)
|
||||
.max(1);
|
||||
let max_delay_ms = policy
|
||||
.and_then(|value| value.max_delay_ms)
|
||||
.unwrap_or(DEFAULT_RECONNECT_MAX_DELAY_MS)
|
||||
.max(initial_delay_ms);
|
||||
let multiplier = policy
|
||||
.and_then(|value| value.multiplier)
|
||||
.filter(|value| value.is_finite() && *value >= 1.0)
|
||||
.unwrap_or(DEFAULT_RECONNECT_MULTIPLIER);
|
||||
let max_attempts = policy
|
||||
.and_then(|value| value.max_attempts)
|
||||
.filter(|value| *value > 0);
|
||||
|
||||
Self {
|
||||
initial_delay_ms,
|
||||
max_delay_ms,
|
||||
multiplier,
|
||||
max_attempts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct DesktopEventTransportConfig {
|
||||
stream: DesktopEventStreamConfig,
|
||||
reconnect: ResolvedDesktopEventReconnectPolicy,
|
||||
}
|
||||
|
||||
impl DesktopEventTransportConfig {
|
||||
fn new(stream: DesktopEventStreamConfig, request: &DesktopEventsStartRequest) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
reconnect: ResolvedDesktopEventReconnectPolicy::resolve(request.reconnect.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_equivalent_start(&self, other: &Self) -> bool {
|
||||
self.reconnect == other.reconnect
|
||||
&& self.stream.base_url == other.stream.base_url
|
||||
&& self.stream.events_url == other.stream.events_url
|
||||
&& self.stream.client_id == other.stream.client_id
|
||||
&& self.stream.cookie_name == other.stream.cookie_name
|
||||
&& self.stream.session_cookie == other.stream.session_cookie
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceEventBatchPayload {
|
||||
generation: u64,
|
||||
sequence: u64,
|
||||
emitted_at: u128,
|
||||
events: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopEventStreamStatusPayload {
|
||||
generation: u64,
|
||||
state: &'static str,
|
||||
reconnect_attempt: u32,
|
||||
terminal: bool,
|
||||
reason: Option<String>,
|
||||
next_delay_ms: Option<u64>,
|
||||
status_code: Option<u16>,
|
||||
stats: DesktopEventTransportStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopEventTransportStats {
|
||||
raw_events: u64,
|
||||
emitted_events: u64,
|
||||
emitted_batches: u64,
|
||||
delta_coalesces: u64,
|
||||
snapshot_coalesces: u64,
|
||||
status_coalesces: u64,
|
||||
superseded_deltas_dropped: u64,
|
||||
}
|
||||
|
||||
struct DesktopEventTransportState {
|
||||
stop: Option<Arc<AtomicBool>>,
|
||||
config: Option<DesktopEventTransportConfig>,
|
||||
}
|
||||
|
||||
pub struct DesktopEventTransportManager {
|
||||
state: Arc<Mutex<DesktopEventTransportState>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
enum ReaderMessage {
|
||||
Activity,
|
||||
Event(Value),
|
||||
Ping(Value),
|
||||
End(Option<String>),
|
||||
}
|
||||
|
||||
enum PendingEntry {
|
||||
Delta {
|
||||
key: String,
|
||||
scope: String,
|
||||
event: Value,
|
||||
started_at: Instant,
|
||||
},
|
||||
Status {
|
||||
key: String,
|
||||
event: Value,
|
||||
},
|
||||
Snapshot {
|
||||
key: String,
|
||||
event: Value,
|
||||
},
|
||||
Event(Value),
|
||||
}
|
||||
|
||||
enum EventDeliveryPolicy {
|
||||
CoalesceDelta(String),
|
||||
CoalesceStatus(String),
|
||||
CoalesceSnapshot(String),
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
enum OpenStreamErrorKind {
|
||||
Unauthorized,
|
||||
Http,
|
||||
Transport,
|
||||
}
|
||||
|
||||
struct OpenStreamError {
|
||||
kind: OpenStreamErrorKind,
|
||||
message: String,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingBatch {
|
||||
events: Vec<PendingEntry>,
|
||||
}
|
||||
|
||||
impl DesktopEventTransportManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(DesktopEventTransportState {
|
||||
stop: None,
|
||||
config: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
app: AppHandle,
|
||||
stream_config: Option<DesktopEventStreamConfig>,
|
||||
request: Option<DesktopEventsStartRequest>,
|
||||
) -> DesktopEventsStartResult {
|
||||
let Some(stream_config) = stream_config else {
|
||||
return DesktopEventsStartResult {
|
||||
started: false,
|
||||
generation: None,
|
||||
reason: Some("desktop event stream unavailable".to_string()),
|
||||
};
|
||||
};
|
||||
|
||||
let request = request.unwrap_or_default();
|
||||
let transport_config = DesktopEventTransportConfig::new(stream_config, &request);
|
||||
|
||||
let mut state = self.state.lock();
|
||||
if state
|
||||
.config
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.is_equivalent_start(&transport_config))
|
||||
{
|
||||
if let Some(stop) = &state.stop {
|
||||
if !stop.load(Ordering::SeqCst) {
|
||||
return DesktopEventsStartResult {
|
||||
started: true,
|
||||
generation: Some(self.generation.load(Ordering::SeqCst)),
|
||||
reason: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stop) = state.stop.take() {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
state.stop = Some(stop.clone());
|
||||
state.config = Some(transport_config.clone());
|
||||
let shared_generation = self.generation.clone();
|
||||
drop(state);
|
||||
|
||||
thread::spawn(move || {
|
||||
run_transport_loop(app, shared_generation, generation, stop, transport_config)
|
||||
});
|
||||
|
||||
DesktopEventsStartResult {
|
||||
started: true,
|
||||
generation: Some(generation),
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
let mut state = self.state.lock();
|
||||
if let Some(stop) = state.stop.take() {
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
state.config = None;
|
||||
self.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_event(event: &Value) -> EventDeliveryPolicy {
|
||||
if let Some(key) = delta_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceDelta(key);
|
||||
}
|
||||
|
||||
if let Some(key) = status_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceStatus(key);
|
||||
}
|
||||
|
||||
if let Some(key) = snapshot_key(event) {
|
||||
return EventDeliveryPolicy::CoalesceSnapshot(key);
|
||||
}
|
||||
|
||||
EventDeliveryPolicy::Passthrough
|
||||
}
|
||||
|
||||
fn coalesced_payload_event<'a>(event: &'a Value) -> &'a Value {
|
||||
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
|
||||
event.get("event").unwrap_or(event)
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesced_instance_id(event: &Value) -> &str {
|
||||
event
|
||||
.get("instanceId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn snapshot_key(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
let inner_type = inner.get("type")?.as_str()?;
|
||||
let props = inner.get("properties")?;
|
||||
|
||||
match inner_type {
|
||||
"message.part.updated" => {
|
||||
let session_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("sessionID").or_else(|| part.get("sessionId")))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("messageID").or_else(|| part.get("messageId")))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = props
|
||||
.get("part")
|
||||
.and_then(|part| part.get("id"))
|
||||
.and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.part.updated:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
"message.updated" => {
|
||||
let info = props.get("info")?;
|
||||
let session_id = info
|
||||
.get("sessionID")
|
||||
.or_else(|| info.get("sessionId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = info.get("id").and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.updated:{}:{}:{}",
|
||||
instance_id, session_id, message_id
|
||||
))
|
||||
}
|
||||
"session.updated" | "session.status" => {
|
||||
let session_id = props
|
||||
.get("info")
|
||||
.and_then(|info| info.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
props
|
||||
.get("sessionID")
|
||||
.or_else(|| props.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
})?;
|
||||
|
||||
Some(format!("{}:{}:{}", inner_type, instance_id, session_id))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_scope(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
if inner.get("type")?.as_str()? != "message.part.delta" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let props = inner.get("properties")?;
|
||||
let session_id = props
|
||||
.get("sessionID")
|
||||
.or_else(|| props.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let message_id = props
|
||||
.get("messageID")
|
||||
.or_else(|| props.get("messageId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = props
|
||||
.get("partID")
|
||||
.or_else(|| props.get("partId"))
|
||||
.and_then(Value::as_str)?;
|
||||
|
||||
Some(format!(
|
||||
"message.part:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
|
||||
fn delta_key(event: &Value) -> Option<String> {
|
||||
let scope = delta_scope(event)?;
|
||||
let props = coalesced_payload_event(event).get("properties")?;
|
||||
let field = props.get("field")?.as_str()?;
|
||||
|
||||
Some(format!("{}:{}", scope, field))
|
||||
}
|
||||
|
||||
fn snapshot_superseded_delta_scope(event: &Value) -> Option<String> {
|
||||
let instance_id = coalesced_instance_id(event);
|
||||
let inner = coalesced_payload_event(event);
|
||||
if inner.get("type")?.as_str()? != "message.part.updated" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let part = inner.get("properties")?.get("part")?;
|
||||
let session_id = part
|
||||
.get("sessionID")
|
||||
.or_else(|| part.get("sessionId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let message_id = part
|
||||
.get("messageID")
|
||||
.or_else(|| part.get("messageId"))
|
||||
.and_then(Value::as_str)?;
|
||||
let part_id = part.get("id")?.as_str()?;
|
||||
|
||||
Some(format!(
|
||||
"message.part:{}:{}:{}:{}",
|
||||
instance_id, session_id, message_id, part_id
|
||||
))
|
||||
}
|
||||
|
||||
fn append_delta(target: &mut Value, event: &Value) {
|
||||
let next_delta = coalesced_payload_event(event)
|
||||
.get("properties")
|
||||
.and_then(|value| value.get("delta"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(existing_delta) = coalesced_payload_event_mut(target)
|
||||
.and_then(|event| event.get_mut("properties"))
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|props| props.get_mut("delta"))
|
||||
{
|
||||
let combined = existing_delta.as_str().unwrap_or_default().to_string() + next_delta;
|
||||
*existing_delta = Value::String(combined);
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesced_payload_event_mut(event: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
|
||||
if event.get("type").and_then(Value::as_str) == Some("instance.event") {
|
||||
event.get_mut("event").and_then(Value::as_object_mut)
|
||||
} else {
|
||||
event.as_object_mut()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_key(event: &Value) -> Option<String> {
|
||||
match event.get("type")?.as_str()? {
|
||||
"instance.eventStatus" => Some(coalesced_instance_id(event).to_string()),
|
||||
"session.status" => snapshot_key(event),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
impl PendingBatch {
|
||||
pub(super) fn push(&mut self, event: Value, stats: &mut DesktopEventTransportStats) {
|
||||
match classify_event(&event) {
|
||||
EventDeliveryPolicy::CoalesceDelta(key) => {
|
||||
let Some(scope) = delta_scope(&event) else {
|
||||
self.events.push(PendingEntry::Event(event));
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(PendingEntry::Delta {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
..
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
append_delta(existing_event, &event);
|
||||
stats.delta_coalesces = stats.delta_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Delta {
|
||||
key,
|
||||
scope,
|
||||
event,
|
||||
started_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
EventDeliveryPolicy::CoalesceStatus(key) => {
|
||||
if let Some(PendingEntry::Status {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
*existing_event = event;
|
||||
stats.status_coalesces = stats.status_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Status { key, event });
|
||||
}
|
||||
EventDeliveryPolicy::CoalesceSnapshot(key) => {
|
||||
if let Some(part_scope) = snapshot_superseded_delta_scope(&event) {
|
||||
let mut dropped = 0_u64;
|
||||
while matches!(
|
||||
self.events.last(),
|
||||
Some(PendingEntry::Delta { scope, .. }) if scope == &part_scope
|
||||
) {
|
||||
self.events.pop();
|
||||
dropped = dropped.saturating_add(1);
|
||||
}
|
||||
if dropped > 0 {
|
||||
stats.superseded_deltas_dropped =
|
||||
stats.superseded_deltas_dropped.saturating_add(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(PendingEntry::Snapshot {
|
||||
key: existing_key,
|
||||
event: existing_event,
|
||||
}) = self.events.last_mut()
|
||||
{
|
||||
if existing_key == &key {
|
||||
*existing_event = event;
|
||||
stats.snapshot_coalesces = stats.snapshot_coalesces.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.events.push(PendingEntry::Snapshot { key, event });
|
||||
}
|
||||
EventDeliveryPolicy::Passthrough => {
|
||||
self.events.push(PendingEntry::Event(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take_events(&mut self) -> Vec<Value> {
|
||||
let pending = std::mem::take(&mut self.events);
|
||||
pending
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
PendingEntry::Delta { event, .. } => event,
|
||||
PendingEntry::Status { event, .. } => event,
|
||||
PendingEntry::Snapshot { event, .. } => event,
|
||||
PendingEntry::Event(event) => event,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn pending_len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub(super) fn should_hold_single_delta(&self, now: Instant) -> bool {
|
||||
matches!(
|
||||
self.events.as_slice(),
|
||||
[PendingEntry::Delta { started_at, .. }]
|
||||
if now.duration_since(*started_at)
|
||||
< Duration::from_millis(DELTA_STREAM_WINDOW_MS)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
use super::*;
|
||||
use reqwest::blocking::RequestBuilder;
|
||||
|
||||
pub(super) fn build_stream_client() -> Result<Client, OpenStreamError> {
|
||||
Client::builder()
|
||||
.connect_timeout(Duration::from_millis(STREAM_CONNECT_TIMEOUT_MS))
|
||||
.tcp_keepalive(Duration::from_millis(STREAM_TCP_KEEPALIVE_MS))
|
||||
// Note: reqwest's blocking client doesn't expose a per-read timeout.
|
||||
// The global `.timeout()` would kill the entire SSE stream, so we
|
||||
// rely on:
|
||||
// 1. tcp_keepalive to detect dead connections (OS will RST after
|
||||
// several unacked probes, typically ~2 min).
|
||||
// 2. Consumer-side stall detection (STREAM_STALL_TIMEOUT_MS).
|
||||
// 3. Reader thread breaking on channel send error (consumer dropped).
|
||||
.build()
|
||||
.map_err(|error: reqwest::Error| OpenStreamError {
|
||||
kind: OpenStreamErrorKind::Transport,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn open_stream(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
config: &DesktopEventStreamConfig,
|
||||
) -> Result<Response, OpenStreamError> {
|
||||
let url = format!(
|
||||
"{}?clientId={}&connectionId={}",
|
||||
config.events_url, config.client_id, config.connection_id
|
||||
);
|
||||
|
||||
let request = attach_session_cookie(
|
||||
client.get(&url).header("Accept", "text/event-stream"),
|
||||
app,
|
||||
config,
|
||||
);
|
||||
|
||||
let response = request.send().map_err(|error| OpenStreamError {
|
||||
kind: OpenStreamErrorKind::Transport,
|
||||
message: error.to_string(),
|
||||
status_code: None,
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let kind = if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
OpenStreamErrorKind::Unauthorized
|
||||
} else {
|
||||
OpenStreamErrorKind::Http
|
||||
};
|
||||
|
||||
Err(OpenStreamError {
|
||||
kind,
|
||||
message: format!("desktop event stream unavailable ({status})"),
|
||||
status_code: Some(status.as_u16()),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_session_cookie(app: &AppHandle, config: &DesktopEventStreamConfig) -> Option<String> {
|
||||
read_session_cookie_from_webview(app, &config.base_url, &config.cookie_name)
|
||||
.or_else(|| config.session_cookie.clone())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn attach_session_cookie(
|
||||
request: RequestBuilder,
|
||||
app: &AppHandle,
|
||||
config: &DesktopEventStreamConfig,
|
||||
) -> RequestBuilder {
|
||||
attach_session_cookie_value(
|
||||
request,
|
||||
&config.cookie_name,
|
||||
resolve_session_cookie(app, config).as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn attach_session_cookie_value(
|
||||
request: RequestBuilder,
|
||||
cookie_name: &str,
|
||||
session_cookie: Option<&str>,
|
||||
) -> RequestBuilder {
|
||||
let Some(session_cookie) = session_cookie.filter(|value| !value.is_empty()) else {
|
||||
return request;
|
||||
};
|
||||
|
||||
request.header(
|
||||
"Cookie",
|
||||
format!(
|
||||
"{}={}",
|
||||
cookie_name,
|
||||
encode_cookie_header_value(session_cookie)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_cookie_header_value(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
|
||||
for byte in value.bytes() {
|
||||
if is_cookie_header_value_byte(byte) {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
|
||||
encoded
|
||||
}
|
||||
|
||||
fn is_cookie_header_value_byte(byte: u8) -> bool {
|
||||
matches!(
|
||||
byte,
|
||||
b'!' | b'#'..=b'+' | b'-'..=b':' | b'<'..=b'[' | b']'..=b'~'
|
||||
)
|
||||
}
|
||||
|
||||
fn read_session_cookie_from_webview(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
) -> Option<String> {
|
||||
let url = Url::parse(base_url).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let path = url.path();
|
||||
let windows = app.webview_windows();
|
||||
let window = windows.get("main")?;
|
||||
let cookies = window.cookies().ok()?;
|
||||
cookies
|
||||
.into_iter()
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| cookie.name() == cookie_name)
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
|
||||
let Some(domain) = cookie.domain() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let normalized_domain = domain.trim_start_matches('.').to_ascii_lowercase();
|
||||
host == normalized_domain || host.ends_with(&format!(".{}", normalized_domain))
|
||||
})
|
||||
.filter(|cookie: &tauri::webview::cookie::Cookie<'static>| {
|
||||
let Some(cookie_path) = cookie.path() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
path.starts_with(cookie_path)
|
||||
})
|
||||
.map(|cookie: tauri::webview::cookie::Cookie<'static>| cookie.value().to_string())
|
||||
.next()
|
||||
}
|
||||
|
||||
pub(super) fn read_sse(
|
||||
response: Response,
|
||||
tx: SyncSender<ReaderMessage>,
|
||||
stop: Arc<AtomicBool>,
|
||||
generation_atomic: Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
) {
|
||||
let mut reader = BufReader::new(response);
|
||||
let mut line = String::new();
|
||||
let mut event_name: Option<String> = None;
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
|
||||
let _ = tx.send(ReaderMessage::End(Some("stopped".to_string())));
|
||||
return;
|
||||
}
|
||||
|
||||
line.clear();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => {
|
||||
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
|
||||
let _ = tx.send(ReaderMessage::End(Some("stream closed".to_string())));
|
||||
return;
|
||||
}
|
||||
Ok(_) => {
|
||||
if tx.send(ReaderMessage::Activity).is_err() {
|
||||
return; // consumer dropped — stop reading
|
||||
}
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if handle_sse_line(trimmed, &mut event_name, &mut data_lines) {
|
||||
if flush_sse_frame(&tx, &event_name, &data_lines).is_err() {
|
||||
return;
|
||||
}
|
||||
event_name = None;
|
||||
data_lines.clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = flush_sse_frame(&tx, &event_name, &data_lines);
|
||||
let _ = tx.send(ReaderMessage::End(Some(error.to_string())));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sse_line(
|
||||
trimmed: &str,
|
||||
event_name: &mut Option<String>,
|
||||
data_lines: &mut Vec<String>,
|
||||
) -> bool {
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with(':') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(name) = trimmed.strip_prefix("event:") {
|
||||
*event_name = Some(name.strip_prefix(' ').unwrap_or(name).to_string());
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(data) = trimmed.strip_prefix("data:") {
|
||||
data_lines.push(data.strip_prefix(' ').unwrap_or(data).to_string());
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn flush_sse_frame(
|
||||
tx: &SyncSender<ReaderMessage>,
|
||||
event_name: &Option<String>,
|
||||
lines: &[String],
|
||||
) -> Result<(), ()> {
|
||||
let Some(payload) = parse_sse_payload(lines) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if event_name.as_deref() == Some("codenomad.client.ping") {
|
||||
tx.send(ReaderMessage::Ping(payload)).map_err(|_| ())
|
||||
} else {
|
||||
tx.send(ReaderMessage::Event(payload)).map_err(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sse_payload(lines: &[String]) -> Option<Value> {
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = lines.join("\n").trim().to_string();
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(&payload).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_ping_event_is_routed_to_ping_channel() {
|
||||
let (tx, rx) = mpsc::sync_channel(1);
|
||||
let mut event_name = None;
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
assert!(!handle_sse_line(
|
||||
"event: codenomad.client.ping",
|
||||
&mut event_name,
|
||||
&mut data_lines
|
||||
));
|
||||
assert!(!handle_sse_line(
|
||||
r#"data: {"ts":123}"#,
|
||||
&mut event_name,
|
||||
&mut data_lines
|
||||
));
|
||||
assert!(handle_sse_line("", &mut event_name, &mut data_lines));
|
||||
|
||||
flush_sse_frame(&tx, &event_name, &data_lines).expect("ping frame should flush");
|
||||
|
||||
match rx.recv().expect("ping frame should be emitted") {
|
||||
ReaderMessage::Ping(payload) => {
|
||||
assert_eq!(payload.get("ts").and_then(Value::as_u64), Some(123));
|
||||
}
|
||||
_ => panic!("expected ping frame"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_is_attached_to_requests() {
|
||||
let request = attach_session_cookie_value(
|
||||
Client::new().post("http://localhost/api/client-connections/pong"),
|
||||
"codenomad_session",
|
||||
Some("cookie-value"),
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("codenomad_session=cookie-value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_value_is_encoded_before_header_attachment() {
|
||||
let request = attach_session_cookie_value(
|
||||
Client::new().post("http://localhost/api/client-connections/pong"),
|
||||
"codenomad_session",
|
||||
Some("safe;\r\nInjected=bad value"),
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("Cookie")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("codenomad_session=safe%3B%0D%0AInjected=bad%20value")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fresh_stats() -> DesktopEventTransportStats {
|
||||
DesktopEventTransportStats::default()
|
||||
}
|
||||
|
||||
fn stream_config(connection_id: &str) -> DesktopEventStreamConfig {
|
||||
DesktopEventStreamConfig {
|
||||
base_url: "http://127.0.0.1:4096".to_string(),
|
||||
events_url: "http://127.0.0.1:4096/api/events".to_string(),
|
||||
client_id: "tauri-test".to_string(),
|
||||
connection_id: connection_id.to_string(),
|
||||
cookie_name: "codenomad_session".to_string(),
|
||||
session_cookie: Some("cookie-value".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_event(delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": "part-1",
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delta_event_for(part_id: &str, delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": part_id,
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_delta_event(delta: &str) -> Value {
|
||||
json!({
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1",
|
||||
"partID": "part-1",
|
||||
"field": "text",
|
||||
"delta": delta,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_message_part_updated_event(text: &str) -> Value {
|
||||
json!({
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-1",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn message_part_updated_event(text: &str) -> Value {
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-1",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_message_part_delta_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(delta_event(" world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["delta"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_write_wins_for_status_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.eventStatus",
|
||||
"instanceId": "inst-1",
|
||||
"status": "connecting"
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.eventStatus",
|
||||
"instanceId": "inst-1",
|
||||
"status": "connected"
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["status"].as_str(), Some("connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_write_wins_for_consecutive_snapshot_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(message_part_updated_event("Hello"), &mut stats);
|
||||
pending.push(message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_snapshot_keys_keep_order() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(message_part_updated_event("A1"), &mut stats);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"part": {
|
||||
"id": "part-2",
|
||||
"type": "text",
|
||||
"text": "B1",
|
||||
"sessionID": "sess-1",
|
||||
"messageID": "msg-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
pending.push(message_part_updated_event("A2"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["id"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["part"]["id"].as_str(),
|
||||
Some("part-2")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("A2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_replaces_trailing_deltas_for_same_part() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["event"]["type"].as_str(),
|
||||
Some("message.part.updated")
|
||||
);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_events_force_coalesced_flush_before_append() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event("Hello"), &mut stats);
|
||||
pending.push(
|
||||
json!({
|
||||
"type": "instance.event",
|
||||
"instanceId": "inst-1",
|
||||
"event": {
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"id": "msg-1"
|
||||
}
|
||||
}
|
||||
}),
|
||||
&mut stats,
|
||||
);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(
|
||||
events[0]["event"]["type"].as_str(),
|
||||
Some("message.part.delta")
|
||||
);
|
||||
assert_eq!(events[1]["event"]["type"].as_str(), Some("message.updated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_delta_keys_keep_order() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(delta_event_for("part-1", "A1"), &mut stats);
|
||||
pending.push(delta_event_for("part-2", "B1"), &mut stats);
|
||||
pending.push(delta_event_for("part-1", "A2"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[0]["event"]["properties"]["delta"].as_str(),
|
||||
Some("A1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-2")
|
||||
);
|
||||
assert_eq!(
|
||||
events[1]["event"]["properties"]["delta"].as_str(),
|
||||
Some("B1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["partID"].as_str(),
|
||||
Some("part-1")
|
||||
);
|
||||
assert_eq!(
|
||||
events[2]["event"]["properties"]["delta"].as_str(),
|
||||
Some("A2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_delay_grows_and_caps() {
|
||||
let policy = ResolvedDesktopEventReconnectPolicy {
|
||||
initial_delay_ms: 100,
|
||||
max_delay_ms: 500,
|
||||
multiplier: 2.0,
|
||||
max_attempts: None,
|
||||
};
|
||||
|
||||
assert_eq!(compute_reconnect_delay_ms(1, &policy), 100);
|
||||
assert_eq!(compute_reconnect_delay_ms(2, &policy), 200);
|
||||
assert_eq!(compute_reconnect_delay_ms(3, &policy), 400);
|
||||
assert_eq!(compute_reconnect_delay_ms(4, &policy), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holds_single_delta_within_stream_window() {
|
||||
let pending = PendingBatch {
|
||||
events: vec![PendingEntry::Delta {
|
||||
key: "delta-key".to_string(),
|
||||
scope: "delta-scope".to_string(),
|
||||
event: delta_event("Hello"),
|
||||
started_at: Instant::now(),
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(pending.should_hold_single_delta(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushes_single_delta_after_stream_window() {
|
||||
let started_at = Instant::now() - Duration::from_millis(DELTA_STREAM_WINDOW_MS + 1);
|
||||
let pending = PendingBatch {
|
||||
events: vec![PendingEntry::Delta {
|
||||
key: "delta-key".to_string(),
|
||||
scope: "delta-scope".to_string(),
|
||||
event: delta_event("Hello"),
|
||||
started_at,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(!pending.should_hold_single_delta(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_direct_message_part_delta_events() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(direct_delta_event("Hello"), &mut stats);
|
||||
pending.push(direct_delta_event(" world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0]["properties"]["delta"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_snapshot_replaces_trailing_direct_deltas_for_same_part() {
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut stats = fresh_stats();
|
||||
pending.push(direct_delta_event("Hello"), &mut stats);
|
||||
pending.push(direct_message_part_updated_event("Hello world"), &mut stats);
|
||||
|
||||
let events = pending.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["type"].as_str(), Some("message.part.updated"));
|
||||
assert_eq!(
|
||||
events[0]["properties"]["part"]["text"].as_str(),
|
||||
Some("Hello world")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_transport_start_ignores_fresh_connection_id() {
|
||||
let request = DesktopEventsStartRequest::default();
|
||||
let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request);
|
||||
let second = DesktopEventTransportConfig::new(stream_config("conn-2"), &request);
|
||||
|
||||
assert!(first.is_equivalent_start(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_transport_start_detects_material_stream_changes() {
|
||||
let request = DesktopEventsStartRequest::default();
|
||||
let first = DesktopEventTransportConfig::new(stream_config("conn-1"), &request);
|
||||
let mut changed_stream = stream_config("conn-2");
|
||||
changed_stream.session_cookie = Some("other-cookie".to_string());
|
||||
let second = DesktopEventTransportConfig::new(changed_stream, &request);
|
||||
|
||||
assert!(!first.is_equivalent_start(&second));
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
fn send_connection_pong(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
config: &DesktopEventStreamConfig,
|
||||
payload: &Value,
|
||||
) {
|
||||
let body = serde_json::json!({
|
||||
"clientId": config.client_id,
|
||||
"connectionId": config.connection_id,
|
||||
"pingTs": payload.get("ts").and_then(Value::as_u64),
|
||||
});
|
||||
|
||||
let request = client
|
||||
.post(format!(
|
||||
"{}/api/client-connections/pong",
|
||||
config.base_url.trim_end_matches('/')
|
||||
))
|
||||
.json(&body);
|
||||
|
||||
let _ = attach_session_cookie(request, app, config).send();
|
||||
}
|
||||
|
||||
pub(super) fn run_transport_loop(
|
||||
app: AppHandle,
|
||||
generation_atomic: Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
config: DesktopEventTransportConfig,
|
||||
) {
|
||||
let mut reconnect_attempt = 0_u32;
|
||||
let mut stats = DesktopEventTransportStats::default();
|
||||
|
||||
let client = match build_stream_client() {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"error",
|
||||
0,
|
||||
true,
|
||||
Some(error.message),
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(&generation_atomic, generation) {
|
||||
break;
|
||||
}
|
||||
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"connecting",
|
||||
reconnect_attempt,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
|
||||
match open_stream(&app, &client, &config.stream) {
|
||||
Ok(response) => {
|
||||
reconnect_attempt = 0;
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"connected",
|
||||
reconnect_attempt,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
|
||||
let disconnect_reason = consume_stream(
|
||||
&app,
|
||||
&client,
|
||||
&config.stream,
|
||||
response,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&mut stats,
|
||||
);
|
||||
if stop.load(Ordering::SeqCst)
|
||||
|| !generation_matches(&generation_atomic, generation)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if !schedule_retry(
|
||||
&app,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&config.reconnect,
|
||||
&mut reconnect_attempt,
|
||||
"disconnected",
|
||||
disconnect_reason,
|
||||
None,
|
||||
&stats,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let state_name = match error.kind {
|
||||
OpenStreamErrorKind::Unauthorized => "unauthorized",
|
||||
OpenStreamErrorKind::Http | OpenStreamErrorKind::Transport => "error",
|
||||
};
|
||||
|
||||
if !schedule_retry(
|
||||
&app,
|
||||
&generation_atomic,
|
||||
generation,
|
||||
stop.clone(),
|
||||
&config.reconnect,
|
||||
&mut reconnect_attempt,
|
||||
state_name,
|
||||
Some(error.message),
|
||||
error.status_code,
|
||||
&stats,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_status(
|
||||
&app,
|
||||
generation,
|
||||
"stopped",
|
||||
reconnect_attempt,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&stats,
|
||||
);
|
||||
}
|
||||
|
||||
fn schedule_retry(
|
||||
app: &AppHandle,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
policy: &ResolvedDesktopEventReconnectPolicy,
|
||||
reconnect_attempt: &mut u32,
|
||||
state_name: &'static str,
|
||||
reason: Option<String>,
|
||||
status_code: Option<u16>,
|
||||
stats: &DesktopEventTransportStats,
|
||||
) -> bool {
|
||||
*reconnect_attempt = reconnect_attempt.saturating_add(1);
|
||||
let terminal = policy
|
||||
.max_attempts
|
||||
.map(|max_attempts| *reconnect_attempt >= max_attempts)
|
||||
.unwrap_or(false);
|
||||
let next_delay_ms = if terminal {
|
||||
None
|
||||
} else {
|
||||
Some(compute_reconnect_delay_ms(*reconnect_attempt, policy))
|
||||
};
|
||||
|
||||
emit_status(
|
||||
app,
|
||||
generation,
|
||||
state_name,
|
||||
*reconnect_attempt,
|
||||
terminal,
|
||||
reason,
|
||||
next_delay_ms,
|
||||
status_code,
|
||||
stats,
|
||||
);
|
||||
|
||||
if terminal {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(delay_ms) = next_delay_ms {
|
||||
wait_with_cancellation(generation_atomic, generation, stop, delay_ms);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn wait_with_cancellation(
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
delay_ms: u64,
|
||||
) {
|
||||
let mut remaining_ms = delay_ms;
|
||||
while remaining_ms > 0 {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk_ms = remaining_ms.min(100);
|
||||
thread::sleep(Duration::from_millis(chunk_ms));
|
||||
remaining_ms -= chunk_ms;
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_stream(
|
||||
app: &AppHandle,
|
||||
client: &Client,
|
||||
stream_config: &DesktopEventStreamConfig,
|
||||
response: Response,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
generation: u64,
|
||||
stop: Arc<AtomicBool>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) -> Option<String> {
|
||||
let (tx, rx) = mpsc::sync_channel::<ReaderMessage>(4096);
|
||||
let reader_stop = stop.clone();
|
||||
let reader_generation_atomic = generation_atomic.clone();
|
||||
thread::spawn(move || {
|
||||
read_sse(
|
||||
response,
|
||||
tx,
|
||||
reader_stop,
|
||||
reader_generation_atomic,
|
||||
generation,
|
||||
)
|
||||
});
|
||||
|
||||
let mut pending = PendingBatch::default();
|
||||
let mut sequence = 0_u64;
|
||||
let mut last_reader_activity = Instant::now();
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) || !generation_matches(generation_atomic, generation) {
|
||||
return Some("stopped".to_string());
|
||||
}
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(FLUSH_INTERVAL_MS)) {
|
||||
Ok(ReaderMessage::Activity) => {
|
||||
last_reader_activity = Instant::now();
|
||||
}
|
||||
Ok(ReaderMessage::Ping(payload)) => {
|
||||
last_reader_activity = Instant::now();
|
||||
send_connection_pong(app, client, stream_config, &payload);
|
||||
}
|
||||
Ok(ReaderMessage::Event(event)) => {
|
||||
last_reader_activity = Instant::now();
|
||||
stats.raw_events = stats.raw_events.saturating_add(1);
|
||||
|
||||
pending.push(event, stats);
|
||||
if pending.pending_len() >= MAX_BATCH_EVENTS {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(ReaderMessage::End(reason)) => {
|
||||
if !pending.is_empty() {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if last_reader_activity.elapsed() >= Duration::from_millis(STREAM_STALL_TIMEOUT_MS)
|
||||
{
|
||||
if !pending.is_empty() {
|
||||
sequence += 1;
|
||||
emit_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return Some("stream stalled".to_string());
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
if pending.should_hold_single_delta(Instant::now()) {
|
||||
continue;
|
||||
}
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
if !pending.is_empty() {
|
||||
emit_pending_batch(
|
||||
app,
|
||||
generation,
|
||||
&mut pending,
|
||||
&mut sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
return Some("reader disconnected".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_pending_batch(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
pending: &mut PendingBatch,
|
||||
sequence: &mut u64,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
*sequence += 1;
|
||||
emit_batch(
|
||||
app,
|
||||
generation,
|
||||
pending,
|
||||
*sequence,
|
||||
generation_atomic,
|
||||
stats,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_batch(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
pending: &mut PendingBatch,
|
||||
sequence: u64,
|
||||
generation_atomic: &Arc<AtomicU64>,
|
||||
stats: &mut DesktopEventTransportStats,
|
||||
) {
|
||||
if !generation_matches(generation_atomic, generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let events = pending.take_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
stats.emitted_batches = stats.emitted_batches.saturating_add(1);
|
||||
stats.emitted_events = stats.emitted_events.saturating_add(events.len() as u64);
|
||||
|
||||
let _ = app.emit(
|
||||
EVENT_BATCH_NAME,
|
||||
WorkspaceEventBatchPayload {
|
||||
generation,
|
||||
sequence,
|
||||
emitted_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
events,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_status(
|
||||
app: &AppHandle,
|
||||
generation: u64,
|
||||
state_name: &'static str,
|
||||
reconnect_attempt: u32,
|
||||
terminal: bool,
|
||||
reason: Option<String>,
|
||||
next_delay_ms: Option<u64>,
|
||||
status_code: Option<u16>,
|
||||
stats: &DesktopEventTransportStats,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
EVENT_STATUS_NAME,
|
||||
DesktopEventStreamStatusPayload {
|
||||
generation,
|
||||
state: state_name,
|
||||
reconnect_attempt,
|
||||
terminal,
|
||||
reason,
|
||||
next_delay_ms,
|
||||
status_code,
|
||||
stats: stats.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn generation_matches(generation_atomic: &Arc<AtomicU64>, generation: u64) -> bool {
|
||||
generation_atomic.load(Ordering::SeqCst) == generation
|
||||
}
|
||||
|
||||
pub(super) fn compute_reconnect_delay_ms(
|
||||
attempt: u32,
|
||||
policy: &ResolvedDesktopEventReconnectPolicy,
|
||||
) -> u64 {
|
||||
let exponent = attempt.saturating_sub(1) as i32;
|
||||
let scaled = (policy.initial_delay_ms as f64) * policy.multiplier.powi(exponent);
|
||||
(scaled.round().max(policy.initial_delay_ms as f64) as u64).min(policy.max_delay_ms)
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
mod cert_manager;
|
||||
mod cli_manager;
|
||||
mod client_state;
|
||||
mod desktop_event_transport;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_tls;
|
||||
mod managed_node;
|
||||
|
|
@ -12,9 +11,6 @@ mod shutdown;
|
|||
mod windows_update;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use desktop_event_transport::{
|
||||
DesktopEventTransportManager, DesktopEventsStartRequest, DesktopEventsStartResult,
|
||||
};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
|
@ -54,7 +50,6 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
|||
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub desktop_events: DesktopEventTransportManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
|
||||
|
|
@ -138,7 +133,6 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
|||
#[tauri::command]
|
||||
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||
let dev_mode = is_dev_mode();
|
||||
state.desktop_events.stop();
|
||||
state.manager.stop().map_err(|e| e.to_string())?;
|
||||
state
|
||||
.manager
|
||||
|
|
@ -147,21 +141,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_events_start(
|
||||
app: AppHandle,
|
||||
state: tauri::State<AppState>,
|
||||
request: Option<DesktopEventsStartRequest>,
|
||||
) -> DesktopEventsStartResult {
|
||||
let config = state.manager.desktop_event_stream_config();
|
||||
state.desktop_events.start(app, config, request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_events_stop(state: tauri::State<AppState>) {
|
||||
state.desktop_events.stop();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
|
|
@ -598,7 +577,6 @@ fn main() {
|
|||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
desktop_events: DesktopEventTransportManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
remote_proxy_sessions: Mutex::new(HashMap::new()),
|
||||
|
|
@ -660,8 +638,6 @@ fn main() {
|
|||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
desktop_events_start,
|
||||
desktop_events_stop,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
needs_local_certificate_install,
|
||||
|
|
|
|||
|
|
@ -242,7 +242,6 @@ fn cleanup(app: &AppHandle, capture_window: bool) -> Result<(), String> {
|
|||
client_state::flush_and_release_without_window_capture(app);
|
||||
}
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.desktop_events.stop();
|
||||
retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || {
|
||||
state.manager.stop().map_err(|err| err.to_string())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/client": "0.0.0-next-17288",
|
||||
"@opencode-ai/client": "0.0.0-next-17353",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -108,8 +108,6 @@ const App: Component = () => {
|
|||
const {
|
||||
preferences,
|
||||
recentFolders,
|
||||
useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport,
|
||||
serverSettings,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
|
|
@ -571,8 +569,6 @@ const App: Component = () => {
|
|||
|
||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||
preferences,
|
||||
useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { FormAnswer, FormField, FormFields, FormValue } from "@opencode-ai/
|
|||
import { ChevronDown, ExternalLink } from "lucide-solid"
|
||||
import { createMemo, For, Show, type Component } from "solid-js"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { shouldShowProviderAuthField } from "../../lib/provider-auth"
|
||||
import { isProviderAuthHttpUrl, shouldShowProviderAuthField } from "../../lib/provider-auth"
|
||||
|
||||
type Option = { label: string; value: string; description?: string }
|
||||
|
||||
|
|
@ -46,13 +46,15 @@ export const ProviderAuthForm: Component<{
|
|||
<Show
|
||||
when={field.type !== "external"}
|
||||
fallback={(
|
||||
<div class="providers-field">
|
||||
<a href={field.type === "external" ? field.url : ""} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
{title(field)}
|
||||
</a>
|
||||
<Show when={field.description}><span class="settings-toggle-caption">{field.description}</span></Show>
|
||||
</div>
|
||||
<Show when={field.type === "external" && isProviderAuthHttpUrl(field.url)}>
|
||||
<div class="providers-field">
|
||||
<a href={field.type === "external" ? field.url : undefined} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
{title(field)}
|
||||
</a>
|
||||
<Show when={field.description}><span class="settings-toggle-caption">{field.description}</span></Show>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
>
|
||||
<div class="providers-field">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
genericApiMethod,
|
||||
getProviderAuthAnswer,
|
||||
getProviderAuthInitialAnswer,
|
||||
isProviderAuthHttpUrl,
|
||||
isProviderAuthFieldComplete,
|
||||
isAbortError,
|
||||
shouldShowProviderAuthField,
|
||||
|
|
@ -188,19 +189,27 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
return null
|
||||
}
|
||||
|
||||
let popup: Window | null = null
|
||||
try {
|
||||
const popup = window.open("", "_blank")
|
||||
popup = window.open("", "_blank")
|
||||
if (popup && popup.document) {
|
||||
popup.opener = null
|
||||
popup.document.title = t("settings.providers.oauth.popup.loadingTitle")
|
||||
popup.document.body.innerHTML = `<div style=\"font-family: sans-serif; padding: 24px; color: #111;\">${t("settings.providers.oauth.popup.loadingBody")}</div>`
|
||||
}
|
||||
return popup
|
||||
} catch {
|
||||
popup?.close()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function launchAuthorizationUrl(url: string, options?: { popup?: Window | null; sameTab?: boolean }): Promise<boolean> {
|
||||
if (!isProviderAuthHttpUrl(url)) {
|
||||
if (options?.popup && !options.popup.closed) options.popup.close()
|
||||
return false
|
||||
}
|
||||
|
||||
if (options?.sameTab && typeof window !== "undefined") {
|
||||
window.location.assign(url)
|
||||
return true
|
||||
|
|
@ -708,7 +717,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
|
||||
<Show when={stage() === "code"}><div class="providers-form-stack"><div class="providers-oauth-instructions"><ExternalLink class="providers-instructions-icon" /><span>{authorization()?.instructions || t("settings.providers.oauth.enterCode")}</span></div><label class="providers-field"><span class="settings-form-label">{t("settings.providers.oauth.codeLabel")}</span><input ref={(element) => { oauthCodeInput = element }} type="text" class="providers-input" value={code()} onInput={(event) => setCode(event.currentTarget.value)} placeholder={t("settings.providers.oauth.codePlaceholder")} autocomplete="one-time-code" /></label></div></Show>
|
||||
<Show when={stage() === "waiting"}><div class="providers-waiting-card" role="status"><Loader2 class="providers-spin-icon" /><div><div class="settings-toggle-title">{selectedMethod().type === "command" ? t("settings.providers.command.waitingTitle") : t("settings.providers.oauth.waitingTitle")}</div><div class="settings-toggle-caption">{selectedMethod().type === "command" ? commandStatusMessage() ?? t("settings.providers.command.waitingMessage") : authorization()?.instructions}</div></div><button type="button" class="selector-button selector-button-secondary providers-wait-cancel" onClick={cancelOAuthWait}>{t("settings.providers.oauth.cancelWait")}</button></div></Show>
|
||||
<Show when={authorization() && (stage() === "code" || stage() === "waiting")}>
|
||||
<Show when={authorization() && isProviderAuthHttpUrl(authorization()!.url) && (stage() === "code" || stage() === "waiting")}>
|
||||
<div class="providers-oauth-actions">
|
||||
<a href={authorization()?.url} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ export const AdvancedSettingsSection: Component = () => {
|
|||
getBehaviorSettings(config).filter(
|
||||
(setting) =>
|
||||
setting.id === "behavior.autoCleanupBlankSessions" ||
|
||||
setting.id === "behavior.keepUnseenSubagentIdleStatus" ||
|
||||
setting.id === "behavior.tauriNativeEventTransport",
|
||||
setting.id === "behavior.keepUnseenSubagentIdleStatus",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
export const TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY = "codenomad-use-tauri-native-event-transport"
|
||||
|
||||
export function readUseTauriNativeEventTransportPreference(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage?.getItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY) !== "0"
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUseTauriNativeEventTransportPreference(enabled: boolean): void {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage?.setItem(TAURI_NATIVE_EVENT_TRANSPORT_STORAGE_KEY, enabled ? "1" : "0")
|
||||
} catch {
|
||||
// Ignore localStorage failures and keep the in-memory preference only.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
export interface DesktopEventTransportReconnectPolicy {
|
||||
initialDelayMs: number
|
||||
maxDelayMs: number
|
||||
multiplier: number
|
||||
maxAttempts?: number
|
||||
}
|
||||
|
||||
export interface DesktopEventTransportStartOptions {
|
||||
reconnect?: Partial<DesktopEventTransportReconnectPolicy>
|
||||
}
|
||||
|
||||
export type DesktopEventTransportState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "unauthorized"
|
||||
| "error"
|
||||
| "stopped"
|
||||
|
||||
export interface DesktopEventTransportStats {
|
||||
rawEvents: number
|
||||
emittedEvents: number
|
||||
emittedBatches: number
|
||||
deltaCoalesces: number
|
||||
snapshotCoalesces: number
|
||||
statusCoalesces: number
|
||||
supersededDeltasDropped: number
|
||||
}
|
||||
|
||||
export interface DesktopEventTransportStatusPayload {
|
||||
generation: number
|
||||
state: DesktopEventTransportState
|
||||
reconnectAttempt: number
|
||||
terminal: boolean
|
||||
reason?: string
|
||||
nextDelayMs?: number
|
||||
statusCode?: number
|
||||
stats?: DesktopEventTransportStats
|
||||
}
|
||||
|
||||
export interface DesktopEventsStartResult {
|
||||
started: boolean
|
||||
generation?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY: DesktopEventTransportReconnectPolicy = {
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
multiplier: 2,
|
||||
}
|
||||
|
||||
export function resolveDesktopEventTransportStartOptions(
|
||||
options?: DesktopEventTransportStartOptions,
|
||||
): Required<DesktopEventTransportStartOptions> {
|
||||
return {
|
||||
reconnect: {
|
||||
...DEFAULT_DESKTOP_EVENT_RECONNECT_POLICY,
|
||||
...options?.reconnect,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,5 @@
|
|||
import type { WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import {
|
||||
resolveDesktopEventTransportStartOptions,
|
||||
type DesktopEventTransportStartOptions,
|
||||
} from "./event-transport-contract"
|
||||
import { readUseTauriNativeEventTransportPreference } from "./desktop-event-transport-preference"
|
||||
import { getLogger } from "./logger"
|
||||
import { runtimeEnv } from "./runtime-env"
|
||||
import { connectTauriWorkspaceEvents } from "./native/desktop-events"
|
||||
|
||||
const log = getLogger("sse")
|
||||
|
||||
export interface WorkspaceEventTransportCallbacks {
|
||||
onBatch: (events: WorkspaceEventPayload[]) => void
|
||||
|
|
@ -25,7 +15,7 @@ export interface WorkspaceEventConnection {
|
|||
disconnect: () => void
|
||||
}
|
||||
|
||||
async function connectBrowserWorkspaceEvents(
|
||||
export async function connectWorkspaceEvents(
|
||||
callbacks: WorkspaceEventTransportCallbacks,
|
||||
): Promise<WorkspaceEventConnection> {
|
||||
const notifyDisconnected = () => {
|
||||
|
|
@ -45,42 +35,3 @@ async function connectBrowserWorkspaceEvents(
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectWorkspaceEvents(
|
||||
callbacks: WorkspaceEventTransportCallbacks,
|
||||
options?: DesktopEventTransportStartOptions,
|
||||
): Promise<WorkspaceEventConnection> {
|
||||
const nativeDesktopTransportEnabled = readUseTauriNativeEventTransportPreference()
|
||||
|
||||
if (
|
||||
runtimeEnv.host === "tauri" &&
|
||||
runtimeEnv.windowContext === "local" &&
|
||||
nativeDesktopTransportEnabled
|
||||
) {
|
||||
try {
|
||||
const conn = await connectTauriWorkspaceEvents(
|
||||
callbacks,
|
||||
resolveDesktopEventTransportStartOptions(options),
|
||||
)
|
||||
log.info("Event transport: rust-native (desktop_event_transport)")
|
||||
return conn
|
||||
} catch (error) {
|
||||
log.warn("Failed to start native desktop event transport, falling back to browser EventSource", error)
|
||||
}
|
||||
} else if (runtimeEnv.host === "tauri" && runtimeEnv.windowContext === "remote") {
|
||||
log.info("Event transport: browser-eventsource forced for remote Tauri window")
|
||||
} else if (runtimeEnv.host === "tauri") {
|
||||
log.info("Event transport: browser-eventsource forced by settings")
|
||||
}
|
||||
|
||||
log.info(`Event transport: browser-eventsource (host=${runtimeEnv.host})`)
|
||||
return connectBrowserWorkspaceEvents(callbacks)
|
||||
}
|
||||
|
||||
export type {
|
||||
DesktopEventsStartResult,
|
||||
DesktopEventTransportReconnectPolicy,
|
||||
DesktopEventTransportStartOptions,
|
||||
DesktopEventTransportState,
|
||||
DesktopEventTransportStatusPayload,
|
||||
} from "./event-transport-contract"
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ function splitKeywords(key: string): string[] {
|
|||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
useTauriNativeEventTransport: Accessor<boolean>
|
||||
setUseTauriNativeEventTransport: (next: boolean) => void
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
toggleShowMessageTimeline: () => void
|
||||
|
|
@ -412,8 +410,6 @@ export function useCommands(options: UseCommandsOptions) {
|
|||
|
||||
registerBehaviorCommands((command) => commandRegistry.register(command), {
|
||||
preferences: options.preferences,
|
||||
useTauriNativeEventTransport: options.useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport: options.setUseTauriNativeEventTransport,
|
||||
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
|
||||
toggleShowMessageTimeline: options.toggleShowMessageTimeline,
|
||||
|
|
|
|||
|
|
@ -318,8 +318,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Statusanzeigen von Unteragenten beibehalten",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Statusanzeigen von Unteragenten sichtbar lassen, bis sie angesehen wurden.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Nativer Tauri-Ereignistransport",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Den Rust-nativen Desktop-Ereignistransport in Tauri verwenden. Deaktivieren, um auf den Browser-EventSource-Pfad zurückzufallen.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt-Spracheingabe",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Mikrofon-Steuerung für Sprache-zu-Text anzeigen, wenn Sprache konfiguriert ist.",
|
||||
"settings.behavior.promptSubmit.title": "Enter zum Absenden",
|
||||
|
|
|
|||
|
|
@ -318,8 +318,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Keep subagent idle markers visible until viewed instead of hiding them after 5 seconds.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Native Tauri event transport",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Use the Rust-native desktop event transport in Tauri. Disable this to fall back to the browser EventSource path.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enter to submit",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos después de 5 segundos.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactívalo para volver a la ruta browser EventSource.",
|
||||
"settings.behavior.promptVoiceInput.title": "Entrada de voz del prompt",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Muestra el control del micrófono para la entrada de voz a texto del prompt cuando la voz esté configurada.",
|
||||
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.",
|
||||
"settings.behavior.promptVoiceInput.title": "Saisie vocale du prompt",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Afficher le contrôle du microphone pour la saisie vocale du prompt lorsque la voix est configurée.",
|
||||
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "השאר סמני idle של תתי-סוכנים גלויים עד צפייה במקום להסתיר אותם אחרי 5 שניות.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "תעבורת אירועים מקורית של Tauri",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "השתמש בתעבורת האירועים השולחנית המקורית ב-Rust בתוך Tauri. כבה זאת כדי לחזור למסלול browser EventSource.",
|
||||
"settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.",
|
||||
"settings.behavior.promptSubmit.title": "Enter לשליחה",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。",
|
||||
"settings.behavior.promptVoiceInput.title": "プロンプト音声入力",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "音声が設定されている場合、音声文字起こし用のマイク操作を表示します。",
|
||||
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||
|
|
|
|||
|
|
@ -318,8 +318,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "उप-एजेन्ट निष्क्रिय (idle) मार्करहरू राख्नुहोस्",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "नहेरेसम्म उप-एजेन्ट निष्क्रिय मार्करहरू देखिने राख्नुहोस्।",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "नेटिभ Tauri इभेन्ट ट्रान्सपोर्ट",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Tauri मा Rust-नेटिभ डेस्कटप इभेन्ट ट्रान्सपोर्ट प्रयोग गर्नुहोस्। ब्राउजर EventSource मार्गमा फर्कन यसलाई अक्षम गर्नुहोस्।",
|
||||
"settings.behavior.promptVoiceInput.title": "प्रम्प्ट ध्वनि इनपुट",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "वाचन कन्फिगर हुँदा स्पीच-टु-टेक्स्ट प्रम्प्ट इनपुटको लागि माइक्रोफोन नियन्त्रण देखाउनुहोस्।",
|
||||
"settings.behavior.promptSubmit.title": "Enter थिचेर बुझाउनुहोस्",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Оставлять idle-маркеры субагентов",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Оставлять idle-маркеры субагентов видимыми до просмотра вместо скрытия через 5 секунд.",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Нативный транспорт событий Tauri",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "Использовать нативный транспорт событий Rust в Tauri. Отключите, чтобы вернуться к пути browser EventSource.",
|
||||
"settings.behavior.promptVoiceInput.title": "Голосовой ввод промпта",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Показывать микрофон для ввода промпта через распознавание речи, когда речь настроена.",
|
||||
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
||||
|
|
|
|||
|
|
@ -317,8 +317,6 @@ export const settingsMessages = {
|
|||
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记",
|
||||
"settings.behavior.keepUnseenSubagentIdle.subtitle": "让子智能体 idle 标记保持可见直到查看,而不是 5 秒后隐藏。",
|
||||
"settings.behavior.tauriNativeEventTransport.title": "Tauri 原生事件传输",
|
||||
"settings.behavior.tauriNativeEventTransport.subtitle": "在 Tauri 中使用 Rust 原生桌面事件传输。禁用后将回退到浏览器 EventSource 路径。",
|
||||
"settings.behavior.promptVoiceInput.title": "提示词语音输入",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "语音已配置时,显示用于语音转文字提示词输入的麦克风控件。",
|
||||
"settings.behavior.promptSubmit.title": "回车发送",
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import {
|
||||
connectTauriWorkspaceEvents,
|
||||
createTerminalErrorNotifier,
|
||||
mapDesktopEventTransportStatus,
|
||||
} from "./desktop-events.ts"
|
||||
|
||||
describe("createTerminalErrorNotifier", () => {
|
||||
it("calls onError once for repeated terminal notifications", () => {
|
||||
let errors = 0
|
||||
const notifyTerminalError = createTerminalErrorNotifier({
|
||||
onError: () => {
|
||||
errors += 1
|
||||
},
|
||||
})
|
||||
|
||||
notifyTerminalError()
|
||||
notifyTerminalError()
|
||||
|
||||
assert.equal(errors, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapDesktopEventTransportStatus", () => {
|
||||
it("maps native connected state to shared connected state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("connected"), "connected")
|
||||
})
|
||||
|
||||
it("maps native connecting state to shared connecting state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting")
|
||||
})
|
||||
|
||||
it("maps native transient failures to shared disconnected state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected")
|
||||
assert.equal(mapDesktopEventTransportStatus("error"), "disconnected")
|
||||
assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectTauriWorkspaceEvents", () => {
|
||||
it("marks the transport connected when a batch opens the native stream", async () => {
|
||||
let batchHandler: ((event: { payload: any }) => void) | undefined
|
||||
const unlistened: string[] = []
|
||||
const bridge = {
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_events_start") {
|
||||
return { started: true, generation: 1 }
|
||||
}
|
||||
if (command === "desktop_events_stop") {
|
||||
return undefined
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
},
|
||||
listen: async (eventName: string, handler: (event: { payload: any }) => void) => {
|
||||
if (eventName === "desktop:event-batch") {
|
||||
batchHandler = handler
|
||||
}
|
||||
return () => {
|
||||
unlistened.push(eventName)
|
||||
}
|
||||
},
|
||||
} as any
|
||||
|
||||
const statuses: string[] = []
|
||||
const batches: unknown[] = []
|
||||
let opens = 0
|
||||
|
||||
const connection = await connectTauriWorkspaceEvents(
|
||||
{
|
||||
onBatch: (events) => batches.push(events),
|
||||
onOpen: () => {
|
||||
opens += 1
|
||||
},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
},
|
||||
{ reconnect: {} },
|
||||
bridge,
|
||||
)
|
||||
|
||||
assert.ok(batchHandler)
|
||||
batchHandler({
|
||||
payload: {
|
||||
generation: 1,
|
||||
sequence: 1,
|
||||
emittedAt: Date.now(),
|
||||
events: [{ type: "server.heartbeat" }],
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(statuses, ["connected"])
|
||||
assert.equal(opens, 1)
|
||||
assert.equal(batches.length, 1)
|
||||
|
||||
connection.disconnect()
|
||||
assert.deepEqual(unlistened, ["desktop:event-batch", "desktop:event-stream-status"])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import type { WorkspaceEventPayload } from "../../../../server/src/api-types"
|
||||
import type {
|
||||
DesktopEventsStartResult,
|
||||
DesktopEventTransportStartOptions,
|
||||
DesktopEventTransportState,
|
||||
DesktopEventTransportStatusPayload,
|
||||
} from "../event-transport-contract"
|
||||
import type {
|
||||
WorkspaceEventConnection,
|
||||
WorkspaceEventTransportCallbacks,
|
||||
WorkspaceEventTransportStatus,
|
||||
} from "../event-transport"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("sse")
|
||||
|
||||
interface WorkspaceEventBatchPayload {
|
||||
generation: number
|
||||
sequence: number
|
||||
emittedAt: number
|
||||
events: WorkspaceEventPayload[]
|
||||
}
|
||||
|
||||
interface DesktopEventTransportBridge {
|
||||
invoke: typeof invoke
|
||||
listen: typeof listen
|
||||
}
|
||||
|
||||
const defaultDesktopEventTransportBridge: DesktopEventTransportBridge = {
|
||||
invoke,
|
||||
listen,
|
||||
}
|
||||
|
||||
export function createTerminalErrorNotifier(callbacks: Pick<WorkspaceEventTransportCallbacks, "onError">) {
|
||||
let raised = false
|
||||
return () => {
|
||||
if (raised) return
|
||||
raised = true
|
||||
callbacks.onError?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function mapDesktopEventTransportStatus(
|
||||
state: DesktopEventTransportState,
|
||||
): WorkspaceEventTransportStatus {
|
||||
if (state === "connected") return "connected"
|
||||
if (state === "connecting") return "connecting"
|
||||
return "disconnected"
|
||||
}
|
||||
|
||||
export async function connectTauriWorkspaceEvents(
|
||||
callbacks: WorkspaceEventTransportCallbacks,
|
||||
options: DesktopEventTransportStartOptions,
|
||||
bridge: DesktopEventTransportBridge = defaultDesktopEventTransportBridge,
|
||||
): Promise<WorkspaceEventConnection> {
|
||||
let closed = false
|
||||
let opened = false
|
||||
let expectedGeneration: number | null = null
|
||||
const notifyTerminalError = createTerminalErrorNotifier(callbacks)
|
||||
const pendingBatches: WorkspaceEventBatchPayload[] = []
|
||||
const pendingStatuses: DesktopEventTransportStatusPayload[] = []
|
||||
|
||||
const matchesGeneration = (generation: number) => expectedGeneration === generation
|
||||
|
||||
const handleBatchPayload = (payload: WorkspaceEventBatchPayload) => {
|
||||
if (!payload || !matchesGeneration(payload.generation)) return
|
||||
|
||||
if (!opened) {
|
||||
opened = true
|
||||
callbacks.onStatus?.("connected")
|
||||
callbacks.onOpen?.()
|
||||
}
|
||||
|
||||
const events = payload.events ?? []
|
||||
if (events.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
callbacks.onBatch(events)
|
||||
}
|
||||
|
||||
const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => {
|
||||
if (!payload || !matchesGeneration(payload.generation)) return
|
||||
|
||||
callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state))
|
||||
|
||||
if (payload.state === "connected" && !opened) {
|
||||
opened = true
|
||||
callbacks.onOpen?.()
|
||||
}
|
||||
|
||||
if (payload.state === "unauthorized") {
|
||||
log.warn("Native desktop event transport is waiting for authentication", {
|
||||
reason: payload.reason,
|
||||
reconnectAttempt: payload.reconnectAttempt,
|
||||
nextDelayMs: payload.nextDelayMs,
|
||||
stats: payload.stats,
|
||||
})
|
||||
} else if (payload.state === "error") {
|
||||
log.warn("Native desktop event transport reported an error", {
|
||||
reason: payload.reason,
|
||||
reconnectAttempt: payload.reconnectAttempt,
|
||||
nextDelayMs: payload.nextDelayMs,
|
||||
statusCode: payload.statusCode,
|
||||
stats: payload.stats,
|
||||
})
|
||||
} else if ((payload.state === "disconnected" || payload.state === "stopped") && payload.stats) {
|
||||
log.info("Native desktop event transport stats", {
|
||||
state: payload.state,
|
||||
reconnectAttempt: payload.reconnectAttempt,
|
||||
stats: payload.stats,
|
||||
})
|
||||
}
|
||||
|
||||
if (payload.state === "stopped") {
|
||||
notifyTerminalError()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.terminal) {
|
||||
notifyTerminalError()
|
||||
}
|
||||
}
|
||||
|
||||
const flushPending = () => {
|
||||
if (expectedGeneration === null) return
|
||||
for (const payload of pendingStatuses.splice(0, pendingStatuses.length)) {
|
||||
handleStatusPayload(payload)
|
||||
}
|
||||
for (const payload of pendingBatches.splice(0, pendingBatches.length)) {
|
||||
handleBatchPayload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const unlistenBatch = await bridge.listen<WorkspaceEventBatchPayload>("desktop:event-batch", (event) => {
|
||||
if (closed) return
|
||||
const payload = event.payload
|
||||
if (!payload) return
|
||||
if (expectedGeneration === null) {
|
||||
pendingBatches.push(payload)
|
||||
return
|
||||
}
|
||||
handleBatchPayload(payload)
|
||||
})
|
||||
|
||||
const unlistenStatus = await bridge.listen<DesktopEventTransportStatusPayload>("desktop:event-stream-status", (event) => {
|
||||
if (closed) return
|
||||
const payload = event.payload
|
||||
if (!payload) return
|
||||
if (expectedGeneration === null) {
|
||||
pendingStatuses.push(payload)
|
||||
return
|
||||
}
|
||||
handleStatusPayload(payload)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await bridge.invoke<DesktopEventsStartResult>("desktop_events_start", { request: options })
|
||||
if (!result?.started) {
|
||||
throw new Error(result?.reason ?? "desktop event transport unavailable")
|
||||
}
|
||||
expectedGeneration = result.generation ?? null
|
||||
flushPending()
|
||||
} catch (error) {
|
||||
unlistenBatch()
|
||||
unlistenStatus()
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
disconnect() {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
unlistenBatch()
|
||||
unlistenStatus()
|
||||
void bridge.invoke("desktop_events_stop").catch((error) => {
|
||||
log.warn("Failed to stop native desktop event transport", error)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { FormFields } from "@opencode-ai/client"
|
|||
import {
|
||||
getProviderAuthAnswer,
|
||||
getProviderAuthInitialAnswer,
|
||||
isProviderAuthHttpUrl,
|
||||
isProviderAuthFieldComplete,
|
||||
shouldShowProviderAuthField,
|
||||
} from "./provider-auth.ts"
|
||||
|
|
@ -29,4 +30,37 @@ describe("native provider auth answers", () => {
|
|||
assert.equal(isProviderAuthFieldComplete(fields[1], answer), false)
|
||||
assert.equal(isProviderAuthFieldComplete(fields[1], { ...answer, account: "123" }), true)
|
||||
})
|
||||
|
||||
it("accepts only explicit HTTP authorization URLs", () => {
|
||||
assert.equal(isProviderAuthHttpUrl("https://example.com/oauth"), true)
|
||||
assert.equal(isProviderAuthHttpUrl("http://localhost:3000/oauth"), true)
|
||||
assert.equal(isProviderAuthHttpUrl("javascript:alert(1)"), false)
|
||||
assert.equal(isProviderAuthHttpUrl("data:text/html,hello"), false)
|
||||
assert.equal(isProviderAuthHttpUrl("file:///tmp/token"), false)
|
||||
assert.equal(isProviderAuthHttpUrl("//example.com/oauth"), false)
|
||||
})
|
||||
|
||||
it("rejects answers outside supported field constraints", () => {
|
||||
const constrained = [
|
||||
{ key: "code", type: "string", required: true, minLength: 3, maxLength: 5, pattern: "[A-Z]+" },
|
||||
{ key: "email", type: "string", format: "email" },
|
||||
{ key: "count", type: "integer", minimum: 2, maximum: 4 },
|
||||
{ key: "scopes", type: "multiselect", options: [{ label: "Read", value: "read" }, { label: "Write", value: "write" }], minItems: 1, maxItems: 2 },
|
||||
] satisfies FormFields
|
||||
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "AB" }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "ABCDEF" }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "AbC" }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[0], { code: "ABC" }), true)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[1], { email: "invalid" }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[1], { email: "user@example.com" }), true)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 1 }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 2.5 }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 5 }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[2], { count: 3 }), true)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: [] }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["read", "write", "read"] }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["other"] }), false)
|
||||
assert.equal(isProviderAuthFieldComplete(constrained[3], { scopes: ["read"] }), true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,30 @@ export type ProviderAuthAuthorization = {
|
|||
|
||||
export const genericApiMethod: IntegrationKeyMethod = { type: "key", label: "" }
|
||||
|
||||
export function isProviderAuthHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === "http:" || protocol === "https:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStringFormat(value: string, format: Extract<FormField, { type: "string" }>["format"]): boolean {
|
||||
if (!format) return true
|
||||
if (format === "uri") {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (format === "email") return /^[^\s@]+@[^\s@]+$/.test(value)
|
||||
if (format === "date") return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(value) && !Number.isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
export function extractProviderAuthErrorMessage(error: unknown, fallback: string): string {
|
||||
const candidate = error as {
|
||||
data?: { message?: unknown }
|
||||
|
|
@ -47,11 +71,44 @@ export function getProviderAuthAnswer(fields: FormFields | undefined, values: Fo
|
|||
}
|
||||
|
||||
export function isProviderAuthFieldComplete(field: FormField, answer: FormAnswer): boolean {
|
||||
if (field.type === "external" || !field.required) return true
|
||||
if (field.type === "external") return true
|
||||
const value = answer[field.key]
|
||||
if (typeof value === "string") return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
return value !== undefined
|
||||
if (value === undefined) return !field.required
|
||||
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string" || (field.required && value.trim().length === 0)) return false
|
||||
if (field.minLength !== undefined && value.length < field.minLength) return false
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength) return false
|
||||
if (!matchesStringFormat(value, field.format)) return false
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) return false
|
||||
if (field.pattern) {
|
||||
try {
|
||||
if (!new RegExp(`^(?:${field.pattern})$`).test(value)) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return false
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return false
|
||||
if (typeof field.minimum === "number" && value < field.minimum) return false
|
||||
if (typeof field.maximum === "number" && value > field.maximum) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return false
|
||||
if (field.required && value.length === 0) return false
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return false
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return false
|
||||
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
return typeof value === "boolean"
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
} from "../../stores/preferences"
|
||||
import type { Command } from "../commands"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { isTauriHost, isWebHost } from "../runtime-env"
|
||||
import { isWebHost } from "../runtime-env"
|
||||
|
||||
export type BehaviorSettingKind = "toggle" | "enum"
|
||||
|
||||
|
|
@ -36,8 +36,6 @@ export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
|
|||
|
||||
export type BehaviorRegistryActions = {
|
||||
preferences: Accessor<Preferences>
|
||||
useTauriNativeEventTransport: Accessor<boolean>
|
||||
setUseTauriNativeEventTransport: (next: boolean) => void
|
||||
updatePreferences?: (updates: Partial<Preferences>) => void
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
|
|
@ -302,20 +300,6 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||
}
|
||||
},
|
||||
},
|
||||
...(isTauriHost()
|
||||
? [
|
||||
{
|
||||
kind: "toggle" as const,
|
||||
id: "behavior.tauriNativeEventTransport",
|
||||
titleKey: "settings.behavior.tauriNativeEventTransport.title",
|
||||
subtitleKey: "settings.behavior.tauriNativeEventTransport.subtitle",
|
||||
get: () => actions.useTauriNativeEventTransport(),
|
||||
set: (next: boolean) => {
|
||||
actions.setUseTauriNativeEventTransport(next)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptVoiceInput",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import {
|
||||
readUseTauriNativeEventTransportPreference,
|
||||
writeUseTauriNativeEventTransportPreference,
|
||||
} from "../lib/desktop-event-transport-preference"
|
||||
import { storage, type OwnerBucket } from "../lib/storage"
|
||||
import type { RemoteServerProfile } from "../../../server/src/api-types"
|
||||
import {
|
||||
|
|
@ -539,9 +535,6 @@ const [uiConfigBucket, setUiConfigBucket] = createSignal<UiConfigBucket>({})
|
|||
const [serverConfigBucket, setServerConfigBucket] = createSignal<ServerConfigBucket>({})
|
||||
const [uiStateBucket, setUiStateBucket] = createSignal<UiStateBucket>({})
|
||||
const [isLoaded, setIsLoaded] = createSignal(false)
|
||||
const [useTauriNativeEventTransport, setUseTauriNativeEventTransportSignal] = createSignal(
|
||||
readUseTauriNativeEventTransportPreference(),
|
||||
)
|
||||
|
||||
const uiSettings = createMemo<UiSettings>(() => normalizeUiSettings(uiConfigBucket().settings))
|
||||
const themePreference = createMemo<ThemePreference>(() => uiConfigBucket().theme ?? "system")
|
||||
|
|
@ -590,23 +583,6 @@ async function patchConfigOwner(owner: string, patch: unknown) {
|
|||
if (owner === "server") setServerConfigBucket(updated as any)
|
||||
}
|
||||
|
||||
function setUseTauriNativeEventTransport(enabled: boolean): void {
|
||||
if (useTauriNativeEventTransport() === enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
setUseTauriNativeEventTransportSignal(enabled)
|
||||
writeUseTauriNativeEventTransportPreference(enabled)
|
||||
|
||||
void import("../lib/server-events")
|
||||
.then(({ serverEvents }) => {
|
||||
serverEvents.restart("desktop transport preference changed")
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to restart backend events stream after desktop transport preference change", error)
|
||||
})
|
||||
}
|
||||
|
||||
async function patchStateOwner(owner: string, patch: unknown) {
|
||||
await ensureLoaded()
|
||||
const updated = await storage.patchStateOwner(owner, patch)
|
||||
|
|
@ -937,8 +913,6 @@ void ensureLoaded().catch((error: unknown) => {
|
|||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
preferences: typeof preferences
|
||||
useTauriNativeEventTransport: typeof useTauriNativeEventTransport
|
||||
setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport
|
||||
updatePreferences: typeof updatePreferences
|
||||
themePreference: typeof themePreference
|
||||
setThemePreference: typeof setThemePreference
|
||||
|
|
@ -1000,8 +974,6 @@ const ConfigContext = createContext<ConfigContextValue>()
|
|||
const configContextValue: ConfigContextValue = {
|
||||
isLoaded,
|
||||
preferences,
|
||||
useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport,
|
||||
updatePreferences,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
|
|
@ -1091,8 +1063,6 @@ export function useConfig(): ConfigContextValue {
|
|||
|
||||
export {
|
||||
preferences,
|
||||
useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport,
|
||||
uiState,
|
||||
serverSettings,
|
||||
recentFolders,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,29 @@ describe("voice instruction sync", () => {
|
|||
|
||||
assert.deepEqual(calls, ["remove", "shell"])
|
||||
})
|
||||
|
||||
it("serializes concurrent syncs so the latest mode wins remotely", async () => {
|
||||
const calls: string[] = []
|
||||
let releasePut!: () => void
|
||||
const putGate = new Promise<void>((resolve) => { releasePut = resolve })
|
||||
seed({ session: {
|
||||
instructions: { entry: {
|
||||
put: async () => { calls.push("put:start"); await putGate; calls.push("put:end") },
|
||||
remove: async () => { calls.push("remove") },
|
||||
} },
|
||||
command: async () => { calls.push("command") },
|
||||
shell: async () => { calls.push("shell") },
|
||||
} })
|
||||
setConversationModeEnabled(instanceId, true)
|
||||
const first = executeCustomCommand(instanceId, sessionId, "review", "")
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
setConversationModeEnabled(instanceId, false)
|
||||
releasePut()
|
||||
await first
|
||||
|
||||
assert.deepEqual(calls, ["put:start", "put:end", "remove", "command"])
|
||||
assert.equal(calls.filter((call) => call === "remove").length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("native session selection persistence", () => {
|
||||
|
|
|
|||
|
|
@ -20,14 +20,37 @@ const VOICE_MODE_INSTRUCTION = [
|
|||
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||
"After the `spoken` block, continue with your normal detailed response.",
|
||||
].join("\n\n")
|
||||
const voiceInstructionSyncs = new Map<string, { desired: boolean; running: Promise<void> }>()
|
||||
|
||||
async function syncVoiceModeInstruction(client: ReturnType<typeof getRootClient>, instanceId: string, sessionId: string): Promise<void> {
|
||||
const instruction = client.session.instructions.entry
|
||||
if (isConversationModeEnabled(instanceId)) {
|
||||
await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION })
|
||||
} else {
|
||||
await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY })
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
const existing = voiceInstructionSyncs.get(key)
|
||||
if (existing) {
|
||||
existing.desired = isConversationModeEnabled(instanceId)
|
||||
return existing.running
|
||||
}
|
||||
|
||||
const state = { desired: isConversationModeEnabled(instanceId), running: Promise.resolve() }
|
||||
state.running = (async () => {
|
||||
try {
|
||||
let applied: boolean | undefined
|
||||
while (applied !== state.desired) {
|
||||
const desired = state.desired
|
||||
const instruction = client.session.instructions.entry
|
||||
if (desired) {
|
||||
await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION })
|
||||
} else {
|
||||
await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY })
|
||||
}
|
||||
applied = desired
|
||||
state.desired = isConversationModeEnabled(instanceId)
|
||||
}
|
||||
} finally {
|
||||
if (voiceInstructionSyncs.get(key) === state) voiceInstructionSyncs.delete(key)
|
||||
}
|
||||
})()
|
||||
voiceInstructionSyncs.set(key, state)
|
||||
return state.running
|
||||
}
|
||||
|
||||
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
|
||||
|
|
|
|||
|
|
@ -247,7 +247,10 @@ async function fetchSessions(instanceId: string, options?: {
|
|||
log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" })
|
||||
const [response, activeSessions] = await Promise.all([
|
||||
fetchV2Sessions(instanceId, sessionListOptions),
|
||||
getRootClient(instanceId).session.active(),
|
||||
getRootClient(instanceId).session.active().catch((error) => {
|
||||
log.warn("Failed to refresh active sessions", { instanceId, error })
|
||||
return null
|
||||
}),
|
||||
])
|
||||
if (!isLatestSessionListRequest(instanceId, requestId)) {
|
||||
if (options?.strictStatus) throw new Error("Foreground session refresh was superseded")
|
||||
|
|
@ -259,16 +262,20 @@ async function fetchSessions(instanceId: string, options?: {
|
|||
for (const apiSession of getV2SessionItems(response)) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
const existingStatus = existingSession?.status
|
||||
const active = Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id)
|
||||
const status = active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle"
|
||||
const runtimeStatusKnown = true
|
||||
const active = activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id)
|
||||
const status = activeSessions === null
|
||||
? existingStatus ?? "idle"
|
||||
: active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle"
|
||||
const runtimeStatusKnown = activeSessions === null ? existingSession?.runtimeStatusKnown ?? false : true
|
||||
sessionMap.set(apiSession.id, {
|
||||
...toClientSessionV2(instanceId, apiSession, existingSession),
|
||||
status,
|
||||
retry: null,
|
||||
retry: activeSessions === null ? existingSession?.retry ?? null : null,
|
||||
idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince),
|
||||
runtimeStatusKnown,
|
||||
generationRecovery: runtimeStatusKnown
|
||||
generationRecovery: activeSessions === null
|
||||
? existingSession?.generationRecovery ?? null
|
||||
: runtimeStatusKnown
|
||||
? resolveAuthoritativeGenerationRecovery(existingSession?.generationRecovery, status)
|
||||
: existingSession?.generationRecovery ?? null,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ import { messageStoreBus } from "./message-v2/bus"
|
|||
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
const pendingSessionFetches = new Map<string, {
|
||||
status: SessionStatus
|
||||
retry?: SessionRetryState | null
|
||||
}>()
|
||||
const NATIVE_REFRESH_DELAY_MS = 75
|
||||
const nativeRefreshes = new Map<string, {
|
||||
instanceId: string
|
||||
|
|
@ -306,16 +309,24 @@ function ensureSessionStatus(
|
|||
}
|
||||
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
if (pendingSessionFetches.has(key)) return
|
||||
const existingFetch = pendingSessionFetches.get(key)
|
||||
if (existingFetch) {
|
||||
existingFetch.status = status
|
||||
existingFetch.retry = retry
|
||||
return
|
||||
}
|
||||
|
||||
const pendingState = { status, retry }
|
||||
const pending = (async () => {
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
|
||||
if (!fetched) return
|
||||
setSessionStatus(instanceId, sessionId, status, { retry })
|
||||
setSessionStatus(instanceId, sessionId, pendingState.status, { retry: pendingState.retry })
|
||||
})()
|
||||
|
||||
pendingSessionFetches.set(key, pending)
|
||||
void pending.finally(() => pendingSessionFetches.delete(key))
|
||||
pendingSessionFetches.set(key, pendingState)
|
||||
void pending.finally(() => {
|
||||
if (pendingSessionFetches.get(key) === pendingState) pendingSessionFetches.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMessageRole(info?: MessageInfo | null): "user" | "assistant" {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { sdkManager } from "../lib/sdk-manager.ts"
|
|||
import type { Session } from "../types/session.ts"
|
||||
import { addInstance, removeInstance } from "./instances.ts"
|
||||
import { messageStoreBus } from "./message-v2/bus.ts"
|
||||
import { handleNativeSessionEvent, handleSessionIdle } from "./session-events.ts"
|
||||
import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts"
|
||||
import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts"
|
||||
|
||||
const delay = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
|
||||
|
|
@ -79,4 +79,43 @@ describe("native session event reducer", () => {
|
|||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the newest status while an unknown session is hydrating", async () => {
|
||||
const instanceId = "native-status-race"
|
||||
const sessionId = "unknown"
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => {
|
||||
await gate
|
||||
return {
|
||||
id: sessionId, title: sessionId, parentID: null, version: "1", projectID: "project",
|
||||
location: { directory: "/work" }, time: { created: 1, updated: 1 },
|
||||
}
|
||||
},
|
||||
},
|
||||
} as any
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
|
||||
try {
|
||||
handleNativeSessionEvent(instanceId, {
|
||||
type: "session.execution.started", data: { sessionID: sessionId }, location: { directory: "/work" },
|
||||
})
|
||||
handleSessionStatus(instanceId, {
|
||||
type: "session.status", data: { sessionID: sessionId, status: { type: "idle" } },
|
||||
} as any)
|
||||
release()
|
||||
await delay(20)
|
||||
|
||||
assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "idle")
|
||||
assert.equal(sessions().get(instanceId)?.get(sessionId)?.runtimeStatusKnown, true)
|
||||
} finally {
|
||||
setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next })
|
||||
clearInstanceDeletedSessionAuthority(instanceId)
|
||||
removeInstance(instanceId, { authoritative: false })
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -269,6 +269,25 @@ describe("session request authority", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("refreshes sessions when active status is unavailable", async () => {
|
||||
const instanceId = "active-status-unavailable"
|
||||
const { client, cleanup } = setup(instanceId)
|
||||
const existing = { ...session(instanceId, "existing"), status: "working" as const }
|
||||
setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]])))
|
||||
;(client.session as any).list = async () => ({ data: [apiSession(existing.id), apiSession("new")] })
|
||||
;(client.session as any).active = async () => { throw new Error("forbidden") }
|
||||
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
assert.equal(sessions().get(instanceId)?.get(existing.id)?.status, "working")
|
||||
assert.equal(sessions().get(instanceId)?.get(existing.id)?.runtimeStatusKnown, true)
|
||||
assert.equal(sessions().get(instanceId)?.get("new")?.status, "idle")
|
||||
assert.equal(sessions().get(instanceId)?.get("new")?.runtimeStatusKnown, false)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts native absolute session locations without workspace probing", async () => {
|
||||
const instanceId = "unresolved-worktree-status"
|
||||
const { client, cleanup } = setup(instanceId)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue