Compare commits

...
Author SHA1 Message Date
dsh agent c398febf0f Default the workspace doctor to repair
Sync upstream and build images / sync-and-build (push) Successful in 8m20s
Nothing repaired on a plain container update: the image defaulted to
`warn`, which reports lost grouping and stops there, so a project
directory an update erased stayed erased unless the operator set
DSH_WORKSPACE_DOCTOR=repair — a field an existing Unraid container never
receives, because Unraid does not add variables a template gained later.

Bake DSH_WORKSPACE_DOCTOR=repair into the image and make that the
entrypoint's fallback, so an update heals itself with no configuration:
missing project directories are recreated and the chats orphaned with
them are re-accounted at startup, with the registry backed up first and
session logs untouched. `warn` reports without repairing, `off` is
silent, and an unrecognised value repairs rather than silently doing
nothing.

README: the doctor is described as the default with the variable as an
opt-out, and the Unraid note now says an existing container needs no new
field.
2026-09-17 22:26:21 -04:00
dsh agent 3849709cc2 Fix the doctor's invocation path; assert the boot log in CI
Sync upstream and build images / sync-and-build (push) Successful in 8m26s
The entrypoint called /usr/local/bin/dsh-workspace-doctor while the
Dockerfile installs /usr/local/bin/dsh-workspace-doctor.mjs, so every
start logged "...: not found" and the workspace doctor never ran: no
report, no repair. The CI smoke test checked the .mjs path with a direct
`docker exec`, which cannot see a mistake in the entrypoint's own
invocation, so the build stayed green while the container was broken.

Resolve the doctor by name (`command -v dsh-workspace-doctor.mjs`) in
both the root and the non-root path, and log an explicit "not installed
in this image" line when it cannot be found, instead of the shell's bare
"not found". The smoke test now reads the container's boot log: it
requires the doctor's own "dsh-workspace-doctor:" output to appear, and
fails if the entrypoint logs its "workspace doctor:" failure line — that
covers path, exec bit and invocation together, which is the class of
mistake that shipped.

README: manual repair through a one-off container with --entrypoint (dsh
stopped, so nothing races the registry write), and the note that an
existing Unraid container does not pick up the template's new Workspace
Doctor variable on its own.
2026-09-17 22:21:58 -04:00
4 changed files with 79 additions and 23 deletions

No files matched your search

+15
View File
@@ -215,6 +215,21 @@ jobs:
DOCTOR_RC=0
docker exec "$SMOKE" dsh-workspace-doctor.mjs --check > /dev/null || DOCTOR_RC=$?
[ "$DOCTOR_RC" -le 1 ]
# ...and the entrypoint must actually invoke it. The doctor prints a
# "dsh-workspace-doctor:" line when it runs; the entrypoint logs a
# "workspace doctor:" line only when it could not. A direct
# `docker exec` above sails past a wrong path or a lost exec bit, so
# the boot log, not the binary, is what proves the wiring.
if ! docker logs "$SMOKE" 2>&1 | grep -q "^dsh-workspace-doctor:"; then
echo "the workspace doctor did not run at boot"
docker logs "$SMOKE" | tail -30
exit 1
fi
if docker logs "$SMOKE" 2>&1 | grep -q "workspace doctor:"; then
echo "the entrypoint could not run the workspace doctor"
docker logs "$SMOKE" | tail -30
exit 1
fi
OK=""
for i in $(seq 1 30); do
if docker exec "$SMOKE" node -e '
+4
View File
@@ -62,11 +62,15 @@ RUN set -eux; \
# installs) lives inside the mounted state volume, never the rootfs.
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
# them at startup. Unraid's share default is 99:100.
# DSH_WORKSPACE_DOCTOR: the entrypoint's workspace-grouping step. `repair` is
# the default so an update heals the projects whose directories the update
# erased; `warn` reports without repairing, `off` is silent.
ENV DSH_HOME=/home/node/.dsh \
PNPM_HOME=/home/node/.dsh/.pnpm \
XDG_CACHE_HOME=/home/node/.dsh/.cache \
XDG_STATE_HOME=/home/node/.dsh/.state \
XDG_CONFIG_HOME=/home/node/.dsh/.config \
DSH_WORKSPACE_DOCTOR=repair \
PUID=1000 \
PGID=1000
+26 -9
View File
@@ -123,10 +123,11 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
- **Workspace grouping is checked before every start.** A project directory outside the
mounted `/workspace` and state volume does not survive a container update, and dsh joins
a session to its project only at creation — so those chats collect under "Ungrouped" and
stay there. The entrypoint reports every such project and every recoverable chat, and
with `DSH_WORKSPACE_DOCTOR=repair` recreates the missing directories and re-accounts the
orphaned sessions at startup. Session logs are never touched; the workspace registry is
backed up first. See [Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
stay there. Before every start the entrypoint recreates the missing project directories
and re-accounts the orphaned chats, so an update heals itself with no configuration.
Session logs are never touched; the workspace registry is backed up first.
`DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent. See
[Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
#### Operations
- **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly,
@@ -229,10 +230,16 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
<Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
<Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
<Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. warn (default in the image) only reports; repair recreates project directories that the container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config>
<Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. repair (the image default, so leaving this alone is fine) recreates project directories a container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); warn only reports; off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config>
</Container>
```
An **existing** container keeps the fields it was created with: Unraid does not add a
variable a template gained later. That is fine here — the image default is already
`repair`, so the Workspace Doctor row above only matters if you want to differ from it
(`warn` reports without repairing, `off` is silent), set by hand under Docker → Edit →
*Add another Path, Port, Variable, Label or Device* → Variable.
Behind a Cloudflare Tunnel, add the public hostname as a `--trusted-host` and have the
tunnel rewrite `Host` to that name (and strip `Origin`) so the fence sees a name it
trusts.
@@ -250,7 +257,7 @@ trusts.
| `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. |
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless `DSH_CHOWN_WORKSPACE=1`. |
| `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` |
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`warn` default, `repair` fixes lost grouping, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume |
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`repair` default — reports and repairs lost grouping at every start; `warn` reports only, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume |
| Labels | `dev.milesward.dsh-version`, `.pnpm-version`, `.base-digest`, `.recipe-hash` — what CI's freshness check reads |
Four rules that are easy to get wrong:
@@ -322,15 +329,25 @@ runs once per state volume, on the first project, and never again.) Two conseque
chat into it. The old chats stay under "Ungrouped".
Before every start the entrypoint reports this — each project with its storage, each
missing directory, every chat it could recover — and with `DSH_WORKSPACE_DOCTOR=repair`
it fixes it at startup:
missing directory, every chat it could recover — and repairs it, with no configuration to
set. `DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent:
| | |
|---|---|
| Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists |
| Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in |
| Before writing | copies the workspace registry to `workspace.json.bak-<timestamp>`; the write itself is atomic and in dsh's own format |
| By hand | `docker exec -u 1000:1000 <container> dsh-workspace-doctor.mjs --check` (add `--all --dry-run` to preview a repair) |
| By hand | report with `docker exec <container> dsh-workspace-doctor.mjs --check`; repair with the one-off container below |
To repair without waiting for a container start, stop the container first so nothing races
the registry write, then run the doctor against the state volume directly (`--entrypoint`
bypasses dsh, and the image's `PUID`/`PGID` are applied to what it writes):
```sh
docker run --rm --entrypoint /usr/local/bin/dsh-workspace-doctor.mjs \
-v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
gitea.milesward.dev/mward4/deepseek-harness:latest --all
```
The recreated directory is empty: this restores the *grouping*, not the files — those lived
in the container and nothing here can bring them back. Keep project data under `/workspace`
+34 -14
View File
@@ -133,21 +133,38 @@ if [ "$(id -u)" = 0 ]; then
# session is created, against the project directory's canonical path — and a
# directory outside the mounted /workspace and state volume does not survive
# a container update. Those chats then collect under "Ungrouped" for good,
# because the one-time history bootstrap never runs again. Report it always;
# recreate the directories and re-account the orphaned sessions when asked
# (backup first, session logs never touched). See README:
# "Chats land in Ungrouped after an update".
case "${DSH_WORKSPACE_DOCTOR:-warn}" in
# because the one-time history bootstrap never runs again. The default is to
# repair: recreate the directories and re-account the orphaned sessions
# (backup first, session logs never touched), so a container update heals
# itself with no configuration. DSH_WORKSPACE_DOCTOR=warn only reports,
# =off is silent. See README: "Chats land in Ungrouped after an update".
#
# Resolve it by name: the installed file is dsh-workspace-doctor.mjs, and a
# path spelled out here is exactly how a "not found" reaches production. The
# CI smoke test asserts this step's output appears in the boot log, so a
# rename or a wrong spelling fails the build instead of the container start.
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
case "${DSH_WORKSPACE_DOCTOR:-repair}" in
0|off|no) ;;
repair|all|1|yes)
/usr/local/bin/dsh-workspace-doctor --all \
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
# The doctor runs as root here; hand what it wrote back to the runtime user.
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
warn|check|report)
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above; DSH_WORKSPACE_DOCTOR defaults to repair, so this start was told not to fix it"
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT checked. Update the image."
fi
;;
*)
/usr/local/bin/dsh-workspace-doctor --check \
|| log "workspace doctor: see the report above - set DSH_WORKSPACE_DOCTOR=repair to fix it at startup"
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --all \
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
# The doctor runs as root here; hand what it wrote back to the runtime user.
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
else
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
log " workspace grouping was NOT repaired. Update the image."
fi
;;
esac
@@ -163,6 +180,9 @@ if [ ! -w "$STATE" ]; then
log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>"
exit 1
fi
/usr/local/bin/dsh-workspace-doctor --check \
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
if [ -n "$DOCTOR" ]; then
"$DOCTOR" --check \
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
fi
exec dsh "$@"