From dec424ec2a1d6b460b2880133e93a574ce6d2c34 Mon Sep 17 00:00:00 2001
From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com>
Date: Fri, 9 Oct 2026 08:07:59 +0000
Subject: [PATCH] fix(desktop): keep a saved key to its service, do not repeat
job events, show attachment previews
The model settings claimed a saved key and asked the node to reuse it
whatever provider or address was now chosen. They now track the service
the key was saved for. Job event streams resume after the last event
shown, so reopening them repeats nothing. Image previews made from blob
URLs are allowed by the page's content policy. The docs no longer speak
of a Rust daemon.
---
AGENTS.md | 2 +-
apps/sisyphus/README.md | 2 +-
apps/sisyphus/src/renderer/index.html | 2 +-
.../src/renderer/src/pages/OperationsPage.tsx | 10 +++++++-
.../src/renderer/src/pages/SettingsPage.tsx | 24 +++++++++++++------
apps/sisyphusd/README.md | 2 +-
6 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index eb8b7e8..3c70c68 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,6 @@
- Treat cross-platform behavior as a design requirement for daemon code. Keep domain logic independent of operating-system-specific paths and APIs.
- The daemon is `apps/sisyphusd`, written in Go. Build it with `make build`; run `bin/sisyphusd run`. The earlier Rust implementation was retired; do not restore it as the development runtime.
- Never hardcode home-directory paths, path separators, or assumptions about a writable current directory. Use Go's `path/filepath` and the existing daemon data-directory helpers.
-- Put persistent node data in the OS-appropriate application data directory. The SQLite database contains the private node identity, so protect the database and its containing directory with restrictive permissions where the platform supports them; rely on the user's inherited ACL on Windows unless a dedicated ACL implementation is introduced.
+- Put persistent node data in the OS-appropriate application data directory. The node's private key is the file `node.key` in that directory, and the SQLite database beside it holds its pool's members and jobs, so protect the directory and both files with restrictive permissions where the platform supports them; rely on the user's inherited ACL on Windows unless a dedicated ACL implementation is introduced.
- Isolate unavoidable OS-specific behavior behind small helpers and Go build tags. Keep a portable fallback where possible.
- Add tests for path handling and platform-specific behavior. Verify supported targets in CI before claiming support; the Nix dev shell currently targets Linux and macOS.
diff --git a/apps/sisyphus/README.md b/apps/sisyphus/README.md
index e50e525..5b8008c 100644
--- a/apps/sisyphus/README.md
+++ b/apps/sisyphus/README.md
@@ -33,7 +33,7 @@ The Electron main process is the gRPC client. It reads the shared `proto/sisyphu
The desktop client connects to one local daemon. It shows live node and peer status, manages peer compute permissions, and exposes the Go daemon's chat/model, job, file, and pool APIs. Chat model-provider configuration and credentials belong to the local daemon; the desktop UI does not call model providers directly. Files are limited to 256 MiB in this client and are sent to the daemon in bounded gRPC chunks.
-This is still a development client, not a packaged product or remotely accessible Web UI. It does not start or manage the daemon process: start `sisyphusd` separately. Public-node browsing, remote authentication, daemon lifecycle management, and production packaging remain future work. The Rust libp2p daemon does not implement the Go daemon's job, chat, file, and pool API surface.
+This is still a development client, not a packaged product or remotely accessible Web UI. It does not start or manage the daemon process: start `sisyphusd` separately. Public-node browsing, remote authentication, daemon lifecycle management, and production packaging remain future work.
Run checks with:
diff --git a/apps/sisyphus/src/renderer/index.html b/apps/sisyphus/src/renderer/index.html
index 3173873..61087cf 100644
--- a/apps/sisyphus/src/renderer/index.html
+++ b/apps/sisyphus/src/renderer/index.html
@@ -5,7 +5,7 @@
Sisyphus
diff --git a/apps/sisyphus/src/renderer/src/pages/OperationsPage.tsx b/apps/sisyphus/src/renderer/src/pages/OperationsPage.tsx
index 05dca56..eba08b2 100644
--- a/apps/sisyphus/src/renderer/src/pages/OperationsPage.tsx
+++ b/apps/sisyphus/src/renderer/src/pages/OperationsPage.tsx
@@ -35,6 +35,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const [tab, setTab] = useState<'jobs' | 'files' | 'pool'>('jobs')
const [jobs, setJobs] = useState([])
const [jobEvents, setJobEvents] = useState>({})
+ const lastEventSeq = useRef>({})
const [files, setFiles] = useState([])
const [members, setMembers] = useState([])
const [workload, setWorkload] = useState('primes')
@@ -120,7 +121,14 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const liveJobIds = displayedJobs.filter((job) => ['JOB_STATE_PENDING', 'JOB_STATE_RUNNING', 1, 2].includes(job.state)).map((job) => job.jobId).join(',')
useEffect(() => {
const ids = liveJobIds ? liveJobIds.split(',') : []
- const stops = ids.map((jobId) => getNodeApi().stream<{ kind: string; text: string; workerName: string }>('watchJobEvents', { jobId }, (event) => setJobEvents((current) => ({ ...current, [jobId]: [...(current[jobId] ?? []).slice(-19), event] })), () => {}))
+ // Each stream picks up after the last event already shown, so that
+ // reopening them when the set of live jobs changes repeats nothing.
+ const stops = ids.map((jobId) => getNodeApi().stream<{ seq?: string | number; kind: string; text: string; workerName: string }>('watchJobEvents', { jobId, afterSeq: lastEventSeq.current[jobId] ?? 0 }, (event) => {
+ const seq = Number(event.seq ?? 0)
+ if (seq && seq <= (lastEventSeq.current[jobId] ?? 0)) return
+ if (seq) lastEventSeq.current[jobId] = seq
+ setJobEvents((current) => ({ ...current, [jobId]: [...(current[jobId] ?? []).slice(-19), event] }))
+ }, () => {}))
return () => stops.forEach((stop) => stop())
}, [liveJobIds])
async function uploadFile(file?: File) {
diff --git a/apps/sisyphus/src/renderer/src/pages/SettingsPage.tsx b/apps/sisyphus/src/renderer/src/pages/SettingsPage.tsx
index e946c7c..66949b9 100644
--- a/apps/sisyphus/src/renderer/src/pages/SettingsPage.tsx
+++ b/apps/sisyphus/src/renderer/src/pages/SettingsPage.tsx
@@ -31,7 +31,13 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
const [pulling, setPulling] = useState(false)
const pullCancel = useRef<(() => void) | null>(null)
const currentProvider = providers.find((item) => item.id === provider)
- const keepApiKey = !apiKey && hasApiKey && provider === currentProvider?.id
+ // The node keeps a key for one service: the provider and address it was
+ // saved with. Pointed anywhere else, this page must neither claim a key
+ // is saved nor ask the node to reuse it.
+ const [savedService, setSavedService] = useState({ provider: '', baseUrl: '' })
+ const sameAddress = (a: string, b: string, fallback: string) => (a || fallback).replace(/\/+$/, '') === (b || fallback).replace(/\/+$/, '')
+ const keyIsForThisService = hasApiKey && provider === savedService.provider && sameAddress(baseUrl, savedService.baseUrl, currentProvider?.defaultUrl ?? '')
+ const keepApiKey = !apiKey && keyIsForThisService
useEffect(() => {
let active = true
@@ -47,6 +53,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
setBaseUrl(config.baseUrl || selectedProvider?.defaultUrl || '')
setModel(config.model ?? '')
setHasApiKey(Boolean(config.hasApiKey))
+ setSavedService({ provider: config.provider ?? '', baseUrl: config.baseUrl ?? '' })
}).catch(() => { /* The local daemon may not be running yet. */ })
return () => { active = false; pullCancel.current?.() }
}, [])
@@ -55,7 +62,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
if (!provider) return
setLoadingModels(true)
try {
- const result = await getNodeApi().call<{ models?: string[]; details?: typeof models }>('listModels', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey } })
+ const result = await getNodeApi().call<{ models?: string[]; details?: typeof models }>('listModels', { service: { provider, baseUrl, apiKey, keepApiKey } })
const details = result.details ?? (result.models ?? []).map((name) => ({ name, label: '', sizeBytes: 0, tools: 0 }))
setModels(details)
if (details.some((item) => item.name === model)) return
@@ -67,8 +74,11 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
async function saveModel() {
setSavingModel(true)
try {
- const result = await getNodeApi().call<{ hasApiKey?: boolean }>('setModelConfig', { provider, baseUrl, model, apiKey, keepApiKey: !apiKey && hasApiKey })
- setHasApiKey(Boolean(result.hasApiKey ?? (apiKey || hasApiKey)))
+ const result = await getNodeApi().call<{ hasApiKey?: boolean }>('setModelConfig', { provider, baseUrl, model, apiKey, keepApiKey })
+ // The node says whether a key is now saved: it drops the old one
+ // when the service changed and no new one was given.
+ setHasApiKey(Boolean(result.hasApiKey))
+ setSavedService({ provider, baseUrl })
setApiKey('')
toast.success('Model configuration saved')
} catch (error) {
@@ -80,7 +90,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
setPullStatus('Starting download…')
setPullProgress(0)
setPulling(true)
- pullCancel.current = getNodeApi().stream<{ status: string; completedBytes?: string | number; totalBytes?: string | number }>('pullModel', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey }, model: downloadModel.trim() }, (event) => {
+ pullCancel.current = getNodeApi().stream<{ status: string; completedBytes?: string | number; totalBytes?: string | number }>('pullModel', { service: { provider, baseUrl, apiKey, keepApiKey }, model: downloadModel.trim() }, (event) => {
setPullStatus(event.status)
const total = Number(event.totalBytes ?? 0)
const complete = Number(event.completedBytes ?? 0)
@@ -106,7 +116,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
}
async function removeModel(name: string) {
try {
- await getNodeApi().call('removeModel', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey }, model: name })
+ await getNodeApi().call('removeModel', { service: { provider, baseUrl, apiKey, keepApiKey }, model: name })
setModels((current) => current.filter((item) => item.name !== name))
toast.success('Model removed')
} catch (error) { toast.error('Could not remove model', { description: error instanceof Error ? error.message : String(error) }) }
@@ -145,7 +155,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
LOCAL AI
Model provider
Stored on node
Choose the model provider used by the planner. API keys are sent directly to the local daemon and are never displayed again.
setDownloadModel(event.target.value)} placeholder="Model name to download (e.g. llama3.1:8b)" className="min-w-48 flex-1" />{pulling && }
}
diff --git a/apps/sisyphusd/README.md b/apps/sisyphusd/README.md
index 7d91e89..595c2df 100644
--- a/apps/sisyphusd/README.md
+++ b/apps/sisyphusd/README.md
@@ -8,7 +8,7 @@ This is the walking skeleton: a coordinator splits a job into tasks, workers exe
Needs Go 1.27 or newer.
-From the repository root, `nix develop` provides the Go toolchain and development dependencies. The active daemon is the Go binary at `bin/sisyphusd`; the earlier Rust binary under `target/debug` is not used for development.
+From the repository root, `nix develop` provides the Go toolchain and development dependencies. The daemon is the Go binary at `bin/sisyphusd`.
```sh
make build # produces bin/sisyphusd