Skip to content

Commit 6fdc73d

Browse files
mpstatonclaude
andcommitted
fix(workspace, workspaces): the active workspace survives service restarts — no more silent resets to humain-vc
Every workspace-service container rebuild re-ran the boot fallback: initial_active_id empty → slugs[0] alphabetical → humain-vc. Every surface then followed workspace.active into the wrong (empty) tenant slice — "no organizations are loading" / "Augment from DB doesn't load" were this, twice today, not the flow or the remotes (proven by headless browser drives: the workbench mounts clean; it was rendering humain-vc's empty world). The operator's pick now persists to the same durable volume as sessions (ACTIVE_STORE_PATH, /data/active-workspace.json), written through on every switch and restored on boot. Precedence: env pin (ACTIVE_CLIENT_ID) > persisted pick > alphabetical first. Unset path (non-docker dev) degrades to the old in-memory behavior. Seeded to reach-edu; boot log confirms resolved_active_id reach-edu after a rebuild. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent c6fba08 commit 6fdc73d

2 files changed

Lines changed: 40 additions & 4 deletions

File tree

docker-compose.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ services:
2121
environment:
2222
NATS_URL: nats://nats:4222
2323
SESSION_STORE_PATH: /data/sessions.json
24+
# Operator's last workspace pick, restored on boot — without it every
25+
# container rebuild reset the active workspace to the alphabetically
26+
# first slug and every surface followed it into the wrong tenant.
27+
ACTIVE_STORE_PATH: /data/active-workspace.json
2428
PORT: "3001"
2529
# Workspace registry — each child of /clients is a workspace, per
2630
# [[Workspaces-as-Tenant-Primitive]]. ACTIVE_CLIENT_ID is optional;
27-
# absent → first slug alphabetically (humain-vc today).
31+
# absent → persisted last pick, then first slug alphabetically.
2832
CLIENTS_ROOT: /clients
2933
ACTIVE_CLIENT_ID: ${ACTIVE_CLIENT_ID:-}
3034
# didi.sh identity (spec increment 2) — verify the didi_session

services/workspace/src/workspaces.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
// in a later step. Persistence to a JSON file is intentionally deferred
2222
// — multi-user / per-session active workspace is a later spec move.
2323

24-
import { readdir, readFile, stat } from 'node:fs/promises';
24+
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
2525
import { join, resolve } from 'node:path';
2626
import { getNats } from './nats';
2727

@@ -107,11 +107,39 @@ async function loadConfigFor(client_id: string): Promise<WorkspaceConfig> {
107107
return { client_id, env: Object.freeze(env) };
108108
}
109109

110+
// Where the operator's last workspace pick persists across restarts —
111+
// the same durable volume sessions.json lives on. Without this, every
112+
// container rebuild silently reset the active workspace to the
113+
// alphabetically-first slug (humain-vc), and every surface followed it
114+
// into the wrong (empty) tenant slice. Unset (non-docker dev) → skip
115+
// persistence, in-memory only, same as before.
116+
const ACTIVE_STORE_PATH = process.env.ACTIVE_STORE_PATH ?? '';
117+
118+
async function readPersistedActive(): Promise<string | null> {
119+
if (!ACTIVE_STORE_PATH) return null;
120+
try {
121+
const raw = await readFile(ACTIVE_STORE_PATH, 'utf8');
122+
const parsed = JSON.parse(raw) as { client_id?: string };
123+
return typeof parsed.client_id === 'string' ? parsed.client_id : null;
124+
} catch {
125+
return null;
126+
}
127+
}
128+
129+
function persistActive(client_id: string): void {
130+
if (!ACTIVE_STORE_PATH) return;
131+
// Fire-and-forget — a failed persist degrades to the old reset-on-restart
132+
// behavior, never fails the switch itself.
133+
void writeFile(ACTIVE_STORE_PATH, JSON.stringify({ client_id }), 'utf8').catch((err) => {
134+
console.warn('[workspaces] could not persist active workspace', err);
135+
});
136+
}
137+
110138
/**
111139
* Initialize the workspace registry. Scans CLIENTS_ROOT for directories,
112140
* primes each one's WorkspaceConfig, and resolves an initial active slug.
113-
* Active selection precedence: explicit ACTIVE_CLIENT_ID env > alphabetical
114-
* first discovered > null.
141+
* Active selection precedence: explicit ACTIVE_CLIENT_ID env (pinned) >
142+
* persisted last pick (ACTIVE_STORE_PATH) > alphabetical first > null.
115143
*/
116144
export async function initWorkspaces(opts: {
117145
clients_root: string;
@@ -124,8 +152,11 @@ export async function initWorkspaces(opts: {
124152
configs.set(slug, await loadConfigFor(slug));
125153
}
126154
pinned = Boolean(opts.initial_active_id);
155+
const persisted = pinned ? null : await readPersistedActive();
127156
if (opts.initial_active_id && configs.has(opts.initial_active_id)) {
128157
activeClientId = opts.initial_active_id;
158+
} else if (persisted && configs.has(persisted)) {
159+
activeClientId = persisted;
129160
} else {
130161
activeClientId = slugs[0] ?? null;
131162
}
@@ -198,6 +229,7 @@ export function setActiveClientId(client_id: string): WorkspaceSummary {
198229
}
199230
const prev = activeClientId;
200231
activeClientId = client_id;
232+
persistActive(client_id);
201233
// Broadcast the switch so domain services (row-store today; prompt-store
202234
// / response-store / content-ingest next) can re-scope their state to
203235
// the new tenant. Fire-and-forget — publish is local to NATS.

0 commit comments

Comments
 (0)