Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,21 @@ When migrating a tenant onto Stage 1, run the scripts in this order, all during

1. `bun run migrate:personal-workspaces` — renames each user's personal workspace to `ws_user_<userId>` and stamps `isPersonal` / `ownerUserId`.
2. `bun run migrate:conversations-to-top-level` — moves per-workspace conversations to `{workDir}/conversations/`.
3. `bun run heal:truncated-personal-workspaces` — **only if needed.** Some legacy tenants (notably hq) used a 16-char-truncated slug for personal workspaces that step 1 doesn't recognize. Heuristic: step 1's output shows `no personal workspace found (will be created on next login)` for users who actually do have a workspace named `<displayName>'s Workspace` at a short-slug id. If you see that pattern, run this heal script (dry-run first). Idempotent — safe to run on any tenant; it exits cleanly with `no truncated workspace` when nothing matches. All three scripts share the same `.migration-lock` PID file, so they're serialized by construction.
3. `bun run heal:truncated-personal-workspaces` — **only if needed.** Some legacy tenants used a 16-char-truncated slug for personal workspaces that step 1 doesn't recognize. Heuristic: step 1's output shows `no personal workspace found (will be created on next login)` for users who actually do have a workspace named `<displayName>'s Workspace` at a short-slug id. If you see that pattern, run this heal script (dry-run first). Idempotent — safe to run on any tenant; it exits cleanly with `no truncated workspace` when nothing matches. All three scripts share the same `.migration-lock` PID file, so they're serialized by construction.
4. `bun run cleanup:personal-workspace-members` — **only if needed.** Pre-Stage-1.1 data may include multi-admin personal workspaces that the new store invariants reject. Idempotent; dry-run by default, `--apply` to write. A personal workspace missing `ownerUserId` is a hard-error — operator must triage.

### Personal workspace invariants

Personal workspaces (`isPersonal === true`) are sole-owner-by-design. The store enforces four rules and throws `PersonalWorkspaceInvariantError` (`src/workspace/errors.ts`) on violation:

1. **Members locked** to `[{ userId: ownerUserId, role: "admin" }]`. `addMember` / `removeMember` / `updateMemberRole` and `update({ members })` all reject mutations on personal workspaces.
2. **`isPersonal` frozen** post-create (both directions).
3. **`ownerUserId` frozen** on personal workspaces.
4. **`ownerUserId` forbidden** on non-personal workspaces (the two fields travel together).

What stays freely mutable on a personal workspace: `bundles`, `name`, `about`, `customInstructions`. Those are workspace-content edits, not identity edits.

The HTTP layer maps `PersonalWorkspaceInvariantError` to `422 personal_workspace_invariant` with `{ workspaceId, reason }` details (same shape as `ConversationCorruptedError → 422`). The workspace-mgmt tool handlers encode the error into `structuredContent` so it survives the in-process MCP serialization boundary; `handleToolCall` decodes and emits the 422.

## Debug Logging

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
- Top-level `/profile` route. Identity isn't a setting; un-nested from `/settings/*`.
- Org-admin gate on `set_model_config` — backend now refuses non-org-admin writes (was UI-only via RouteGuard). Distinguishes "no identity" (cron, automations) from "wrong role" so debug logs make non-user code paths obvious.
- HTTP proxy primitive (`_meta["ai.nimblebrain/http-proxy"]`). Bundles can expose a loopback HTTP server (e.g. `astro preview`, Jupyter kernel) through the platform at `/v1/ws/<wsId>/apps/<bundle>/<mount>/*`. Loopback-only target, credentials and `Accept-Encoding` stripped on forward, `Set-Cookie`/CSP/X-Frame-Options stripped on response, per-workspace kill switch via `Workspace.allowHttpProxy`. Bundles get `NB_WORKSPACE_ID`, `NB_PROXY_PREFIX`, `NB_PUBLIC_ORIGIN` in their env at spawn ([docs](https://docs.nimblebrain.ai/apps/http-proxy/)).
- `PersonalWorkspaceInvariantError` typed error (`src/workspace/errors.ts`) → HTTP 422 `personal_workspace_invariant` with structured `{ workspaceId, reason }` body. Mirrors the `ConversationCorruptedError` → 422 precedent; raised by `WorkspaceStore` on attempts to mutate the locked members / `isPersonal` / `ownerUserId` fields on personal workspaces.
- `scripts/cleanup-personal-workspace-members.ts` (alias: `bun run cleanup:personal-workspace-members`) — one-off retroactive cleanup that converges pre-Stage-1.1 personal workspaces to the sole-owner-admin shape. Idempotent, dry-run by default.

### Changed

Expand Down Expand Up @@ -87,6 +89,7 @@
- **Removed `manage_conversation` actions:** `shareConversation`, `unshareConversation`, `addParticipant`, `removeParticipant` are gone. Single-owner semantics; sharing returns in a future stage with policy gates. External callers that previously invoked these actions get an `unknown action` error.
- **`Conversation.visibility` and `Conversation.participants` removed from the schema.** Reads of pre-migration files that still carry these fields skip them at parse time; writes never produce them. `ownerId` is now required on every conversation file — pre-migration files without one fail to load with a clear "run the migration" hint.
- **`/v1/conversations/:id/events` no longer requires `X-Workspace-Id`.** The header is still honored when sent (validated for format + membership); absent → 200, present + malformed → 400, present + non-member → 403. Foreign-owner conversation → 403 `conversation_access_denied`; non-existent → 404 `not_found`. Web clients that already send the header keep working unchanged.
- **Personal workspaces enforce sole-owner-admin membership and freeze `isPersonal` / `ownerUserId` post-create.** `WorkspaceStore.update` / `addMember` / `removeMember` / `updateMemberRole` throw `PersonalWorkspaceInvariantError` on any member or identity-field mutation against a personal workspace; `WorkspaceStore.create` rejects a personal workspace whose initial members aren't exactly `[{ userId: ownerUserId, role: "admin" }]`. Operators with pre-existing multi-admin personal workspaces must run `bun run cleanup:personal-workspace-members --apply` to converge — `bundles`, `name`, `about`, `customInstructions` remain freely mutable.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"migrate:personal-workspaces": "bun run scripts/migrate-personal-workspaces.ts",
"migrate:conversations-to-top-level": "bun run scripts/migrate-conversations-to-top-level.ts",
"heal:truncated-personal-workspaces": "bun run scripts/heal-truncated-personal-workspaces.ts",
"cleanup:personal-workspace-members": "bun run scripts/cleanup-personal-workspace-members.ts",
"check:cycles": "bun run scripts/check-cycles.ts",
"check:bundle-transport": "bun run scripts/check-bundle-transport.ts",
"check:workspace-paths": "bun run scripts/check-workspace-paths.ts",
Expand Down
256 changes: 256 additions & 0 deletions scripts/cleanup-personal-workspace-members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
#!/usr/bin/env bun
/**
* Stage 1.1 cleanup: enforce sole-owner-admin membership on existing
* personal workspaces.
*
* Stage 1 introduced personal workspaces (`isPersonal: true`,
* `ownerUserId: <user>`) but didn't enforce that their `members` array
* matched the canonical sole-owner shape. A production tenant
* surfaced a personal workspace with three admins, which Stage 1.1
* now disallows at the store layer. Operators with pre-existing data
* shaped by the looser invariant run this script once to converge.
*
* For each workspace where `isPersonal === true`:
* - If `members` is already `[{ userId: ownerUserId, role: "admin" }]`
* → no-op (idempotent — running twice produces no changes).
* - If `members` contains non-owner entries OR the owner's role is
* not "admin" → rewrite to the canonical shape:
* `[{ userId: ownerUserId, role: "admin" }]`. Any non-owner
* members are dropped from the personal workspace; they retain
* their own personal workspaces and any shared-workspace
* memberships elsewhere.
* - If `ownerUserId` is missing on a personal workspace → hard-error.
* We can't safely guess the owner; an operator must repair
* manually (or delete the workspace if it's an orphan).
*
* Non-personal workspaces are untouched.
*
* Usage:
* bun run scripts/cleanup-personal-workspace-members.ts [--work-dir <path>] [--dry-run | --apply]
*
* Default is dry-run. Use `--apply` to actually write.
*/

import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { writeJsonAtomic } from "../src/util/atomic-json.ts";
import type { Workspace } from "../src/workspace/types.ts";
import { WorkspaceStore } from "../src/workspace/workspace-store.ts";
import { acquireMigrationLock } from "./lib/migration-lock.ts";

interface Args {
workDir: string;
apply: boolean;
}

interface Stats {
workspacesScanned: number;
personalScanned: number;
alreadyClean: number;
cleaned: number;
nonPersonalSkipped: number;
errors: { ctx: string; message: string }[];
}

function parseArgs(): Args {
const argv = process.argv.slice(2);
let workDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain");
// Default to dry-run — operator opts into writes via --apply. This
// mirrors the Stage 1 migration ergonomics; the cleanup is
// destructive (drops non-owner members) so the bias is toward
// visibility-first.
let apply = false;

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else if (arg === "--dry-run") {
apply = false;
} else if (arg === "--apply") {
apply = true;
} else if (arg === "--work-dir") {
workDir = argv[++i] ?? "";
} else if (arg?.startsWith("--work-dir=")) {
workDir = arg.slice("--work-dir=".length);
} else {
console.error(`[cleanup] unknown argument: ${arg}`);
printHelp();
process.exit(2);
}
}

if (!workDir) {
console.error("[cleanup] --work-dir is required (or set NB_WORK_DIR)");
process.exit(2);
}
return { workDir, apply };
}

function printHelp(): void {
console.log(`
cleanup-personal-workspace-members — Stage 1.1 follow-up

Enforces sole-owner-admin membership on every personal workspace.
Non-owner members on personal workspaces are dropped; the owner's
role is forced to "admin".

Usage:
bun run scripts/cleanup-personal-workspace-members.ts [options]

Options:
--work-dir <path> Override the work directory.
Defaults to $NB_WORK_DIR or ~/.nimblebrain.
--dry-run Report planned changes without writing (default).
--apply Actually write changes.
-h, --help This message.

Idempotent: running twice produces no changes the second time.
Run with --dry-run first to verify the plan.
`);
}

/**
* Compute the canonical sole-owner-admin members array for a personal
* workspace. Single source of truth — both the planner and the writer
* use it so the dry-run reflects the same end state as `--apply`.
*/
function canonicalMembers(ownerUserId: string): Workspace["members"] {
return [{ userId: ownerUserId, role: "admin" }];
}

/**
* Decide whether `ws` already matches the canonical sole-owner-admin
* shape. A personal workspace passes iff:
* - exactly one member,
* - that member's `userId` is the workspace's `ownerUserId`,
* - that member's `role` is "admin".
*/
function isCanonical(ws: Workspace): boolean {
if (!ws.ownerUserId) return false;
if (ws.members.length !== 1) return false;
const sole = ws.members[0];
if (!sole) return false;
return sole.userId === ws.ownerUserId && sole.role === "admin";
}

async function main(): Promise<void> {
const args = parseArgs();
console.error(
`[cleanup] workDir=${args.workDir}${args.apply ? " (apply)" : " (dry-run)"}`,
);

const workspacesDir = join(args.workDir, "workspaces");
if (!existsSync(workspacesDir)) {
console.error(`[cleanup] no workspaces dir at ${workspacesDir} — nothing to do`);
return;
}

// Hold the same lock the Stage 1 migrations use. Even a dry-run
// reading state concurrently with a real migration could surface
// inconsistent results.
acquireMigrationLock(args.workDir, "cleanup-personal-workspace-members");

const wsStore = new WorkspaceStore(args.workDir);
const stats: Stats = {
workspacesScanned: 0,
personalScanned: 0,
alreadyClean: 0,
cleaned: 0,
nonPersonalSkipped: 0,
errors: [],
};

const workspaces = await wsStore.list();
for (const ws of workspaces) {
stats.workspacesScanned++;

if (ws.isPersonal !== true) {
stats.nonPersonalSkipped++;
continue;
}

stats.personalScanned++;

if (!ws.ownerUserId) {
// Hard-error rather than guess. A personal workspace with no
// owner is data corruption; running the Stage 1 migration
// (`migrate:personal-workspaces`) should have stamped one, or
// the workspace is an orphan that needs operator triage.
const message = `personal workspace ${ws.id} has isPersonal:true but no ownerUserId — operator action required (stamp manually or delete)`;
console.error(`[cleanup] ERROR: ${message}`);
stats.errors.push({ ctx: ws.id, message });
continue;
}

if (isCanonical(ws)) {
stats.alreadyClean++;
continue;
}

// Report the diff so operators have a clear before/after in the
// dry-run output. Non-owner members are listed by id so the log
// doubles as a record of what got dropped.
const currentSummary = ws.members
.map((m) => `${m.userId}:${m.role}`)
.join(", ");
const dropped = ws.members
.filter((m) => m.userId !== ws.ownerUserId)
.map((m) => m.userId);
const targetSummary = `${ws.ownerUserId}:admin`;
console.error(
`[cleanup] ${ws.id} (owner=${ws.ownerUserId}): members [${currentSummary}] → [${targetSummary}]` +
(dropped.length > 0 ? ` (dropped: ${dropped.join(", ")})` : ""),
);

if (!args.apply) {
stats.cleaned++;
continue;
}

try {
// Write the full record directly — `WorkspaceStore.update` now
// rejects member mutations on personal workspaces (by design),
// and the same is true at the addMember/removeMember layer.
// The cleanup script's purpose is precisely to repair the state
// that the store no longer permits to be reached through normal
// mutation, so it writes around the guard at the filesystem
// layer using the same precedent set by Stage 1's
// `stampNonPersonal` (which also bypassed `update`).
const updated: Workspace = {
...ws,
members: canonicalMembers(ws.ownerUserId),
updatedAt: new Date().toISOString(),
};
const wsPath = join(workspacesDir, ws.id, "workspace.json");
await writeJsonAtomic(wsPath, updated);
stats.cleaned++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[cleanup] ERROR writing ${ws.id}: ${message}`);
stats.errors.push({ ctx: ws.id, message });
}
}

console.error("");
console.error(`[cleanup] summary${args.apply ? "" : " (dry-run)"}:`);
console.error(`[cleanup] workspaces scanned: ${stats.workspacesScanned}`);
console.error(`[cleanup] non-personal skipped: ${stats.nonPersonalSkipped}`);
console.error(`[cleanup] personal scanned: ${stats.personalScanned}`);
console.error(`[cleanup] already canonical: ${stats.alreadyClean}`);
console.error(`[cleanup] ${args.apply ? "cleaned" : "would clean"}:${
args.apply ? " " : " "
} ${stats.cleaned}`);
console.error(`[cleanup] errors: ${stats.errors.length}`);
if (stats.errors.length > 0) {
for (const e of stats.errors) console.error(`[cleanup] [error] ${e.ctx}: ${e.message}`);
process.exit(1);
}
}

main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
8 changes: 5 additions & 3 deletions scripts/migrate-personal-workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,11 @@ async function stampNonPersonal(
if (!wantsPersonalStamp && !wantsAboutStamp) return false;
if (dryRun) return true;

// Write directly — `store.update` strips isPersonal/ownerUserId by
// design (Task 001). For the migration we DO need to write isPersonal,
// so we bypass the patch shape and write the full record.
// Write directly — `store.update` throws PersonalWorkspaceInvariantError
// when an isPersonal/ownerUserId patch is attempted (Stage 1.1). The
// migration legitimately needs to write isPersonal on workspaces that
// don't carry it yet, so we bypass the patch shape and write the full
// record. Same precedent the Stage 1.1 cleanup script follows.
const updated: Workspace = {
...ws,
isPersonal: false,
Expand Down
Loading