From 3debf963315ad3bfedf2aad33b8cf78fe219b3ce Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 13:59:22 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20workstreams=20W2=20=E2=80=94=20edit?= =?UTF-8?q?=20workstreams=20and=20credentials=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workstream.upsert / delete / settings and credential.list / upsert / delete / set_value: settings:write on an undelegated token; written to config.json through the settings store (the whole config validated, nothing written otherwise) and applied to the running daemon at once — new sessions and every session's grant follow it, live sessions keep their homes; workstreams.changed announces it - a credential's value goes to .env (0600) and the daemon's environment and is never sent back: only whether it is set, and where; an OAuth one points at codeoid mcp signin; deleting one a workstream uses is refused - a session's MCP view fingerprint includes its grant: a workstream edited rebuilds that session's backend at its next turn, on every backend Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/schemas.test.ts | 7 ++ packages/protocol/src/schemas.ts | 28 +++++ packages/protocol/src/types.ts | 18 ++++ packages/protocol/src/workstreams.ts | 80 ++++++++++++++ src/daemon/mcp/session-view.ts | 12 ++- src/daemon/session-manager.ts | 144 +++++++++++++++++++++++++- src/daemon/settings/store.ts | 42 ++++++++ src/tests/protocol.test.ts | 4 + src/tests/workstreams-edit.test.ts | 128 +++++++++++++++++++++++ 9 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 src/tests/workstreams-edit.test.ts diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 0592839e..841efe9b 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -165,6 +165,13 @@ const samples: { [T in ClientTypes]: Extract } = { "worktree.trust": { type: "worktree.trust", id: "wt1", dir: "/tmp/repo", recipeHash: "0123456789abcdef0123456789abcdef01234567" }, "workstream.list": { type: "workstream.list", id: "ws1" }, "workstream.resolve": { type: "workstream.resolve", id: "ws2", workdir: "/tmp/repo", workstream: "acme" }, + "workstream.upsert": { type: "workstream.upsert", id: "ws3", item: { id: "acme" } }, + "workstream.delete": { type: "workstream.delete", id: "ws4", workstream: "acme" }, + "workstream.settings": { type: "workstream.settings", id: "ws5", isolateGlobalServers: true, default: null }, + "credential.list": { type: "credential.list", id: "c1" }, + "credential.upsert": { type: "credential.upsert", id: "c2", item: { id: "gh", kind: "env" } }, + "credential.delete": { type: "credential.delete", id: "c3", credential: "gh" }, + "credential.set_value": { type: "credential.set_value", id: "c4", credential: "gh", value: "x" }, "session.turns": { type: "session.turns", id: "r94", sessionId: "s1" }, "session.approvals": { type: "session.approvals", id: "r94a", sessionId: "s1" }, "claude.accounts.list": { type: "claude.accounts.list", id: "ca1" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index b82590e5..eb65e228 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1102,6 +1102,27 @@ export const workstreamResolveSchema = z.object({ workstream: workstreamIdField.optional(), }); +// Items are bounded objects here: their SHAPE is the daemon's config schema's +// to judge (one rule, one error message), not the wire's. +const configItemField = z.record(z.string().max(128), z.unknown()).refine((o) => JSON.stringify(o).length <= 64 * 1024, "too large"); +export const workstreamUpsertSchema = z.object({ ...base, type: z.literal("workstream.upsert"), item: configItemField }); +export const workstreamDeleteSchema = z.object({ ...base, type: z.literal("workstream.delete"), workstream: workstreamIdField }); +export const workstreamSettingsSchema = z.object({ + ...base, + type: z.literal("workstream.settings"), + isolateGlobalServers: z.boolean().optional(), + default: workstreamIdField.nullable().optional(), +}); +export const credentialListSchema = z.object({ ...base, type: z.literal("credential.list") }); +export const credentialUpsertSchema = z.object({ ...base, type: z.literal("credential.upsert"), item: configItemField }); +export const credentialDeleteSchema = z.object({ ...base, type: z.literal("credential.delete"), credential: z.string().min(1).max(64) }); +export const credentialSetValueSchema = z.object({ + ...base, + type: z.literal("credential.set_value"), + credential: z.string().min(1).max(64), + value: z.string().max(LIMITS.SETTING_VALUE_MAX).nullable(), +}); + export const skillGrantSchema = z.object({ ...base, type: z.literal("skill.grant"), @@ -1337,6 +1358,13 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ worktreeTrustSchema, workstreamListSchema, workstreamResolveSchema, + workstreamUpsertSchema, + workstreamDeleteSchema, + workstreamSettingsSchema, + credentialListSchema, + credentialUpsertSchema, + credentialDeleteSchema, + credentialSetValueSchema, sessionTurnsSchema, sessionApprovalsSchema, claudeAccountsListSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index c4bf07ce..0f08e754 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -45,12 +45,21 @@ import type { import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js"; import type { WorktreeTrustMsg, WorktreeTrustResultMsg } from "./worktree-trust.js"; import type { + CredentialDeleteMsg, + CredentialListMsg, + CredentialListResultMsg, + CredentialSetValueMsg, + CredentialUpsertMsg, SessionWorkstreamInfo, + WorkstreamDeleteMsg, WorkstreamListMsg, WorkstreamListResultMsg, WorkstreamResolution, WorkstreamResolveMsg, WorkstreamResolveResultMsg, + WorkstreamSettingsMsg, + WorkstreamsChangedMsg, + WorkstreamUpsertMsg, } from "./workstreams.js"; import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js"; import type { SessionApprovalsMsg, SessionApprovalsResultMsg } from "./approvals.js"; @@ -1357,6 +1366,13 @@ export type ClientMessage = | WorktreeTrustMsg | WorkstreamListMsg | WorkstreamResolveMsg + | WorkstreamUpsertMsg + | WorkstreamDeleteMsg + | WorkstreamSettingsMsg + | CredentialListMsg + | CredentialUpsertMsg + | CredentialDeleteMsg + | CredentialSetValueMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -3281,6 +3297,8 @@ export type DaemonMessage = | WorktreeTrustResultMsg | WorkstreamListResultMsg | WorkstreamResolveResultMsg + | WorkstreamsChangedMsg + | CredentialListResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index 36d25467..dd03374c 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -141,3 +141,83 @@ export interface WorkstreamResolveResultMsg { revision: string; resolution: WorkstreamResolution; } + +// ── Editing (W2): settings:write on an undelegated token ───────────────────── + +/** Add or replace one workstream (by `item.id`). The whole config is validated; nothing is written if it doesn't. Replies `workstream.list.result`. */ +export interface WorkstreamUpsertMsg { + type: "workstream.upsert"; + id: string; + /** A `workstreams.items[]` entry as config.json takes it (docs/CONFIGURATION.md, Workstreams). */ + item: Record; +} + +/** Remove a workstream. Sessions homed there keep their binding, shown orphaned. Replies `workstream.list.result`. */ +export interface WorkstreamDeleteMsg { + type: "workstream.delete"; + id: string; + workstream: string; +} + +/** The block-wide settings. `default: null` removes the default. Replies `workstream.list.result`. */ +export interface WorkstreamSettingsMsg { + type: "workstream.settings"; + id: string; + isolateGlobalServers?: boolean; + default?: string | null; +} + +/** Sent to subscribed clients after the workstreams or credentials change. */ +export interface WorkstreamsChangedMsg { + type: "workstreams.changed"; + revision: string; +} + +// ── Credentials (W2) ───────────────────────────────────────────────────────── + +export interface CredentialInfo { + id: string; + kind: "bearer" | "header" | "env" | "oauth"; + header?: string; + server?: string; + description?: string; + /** Whether a value is set, and where (never the value). An `oauth` credential's is its sign-in, shown with its server. */ + status: { set: boolean; source?: string }; + /** Workstreams that reference it. */ + usedBy: string[]; +} + +/** The declared credentials and where their values live. Needs `settings:read`. */ +export interface CredentialListMsg { + type: "credential.list"; + id: string; +} + +/** Declare or replace one credential (by `item.id`). Replies `credential.list.result`. */ +export interface CredentialUpsertMsg { + type: "credential.upsert"; + id: string; + item: Record; +} + +/** Remove a credential's declaration (refused while a workstream references it) and its value. */ +export interface CredentialDeleteMsg { + type: "credential.delete"; + id: string; + credential: string; +} + +/** Set (or with null, clear) a credential's value in a writable store. The value is never sent back. */ +export interface CredentialSetValueMsg { + type: "credential.set_value"; + id: string; + credential: string; + value: string | null; +} + +export interface CredentialListResultMsg { + type: "credential.list.result"; + requestId: string; + store: { kind: string; writable: boolean }; + credentials: CredentialInfo[]; +} diff --git a/src/daemon/mcp/session-view.ts b/src/daemon/mcp/session-view.ts index d4e3c866..b7eab395 100644 --- a/src/daemon/mcp/session-view.ts +++ b/src/daemon/mcp/session-view.ts @@ -110,9 +110,17 @@ export class SessionMcpView implements McpServerView { return this.#loaded; } - /** What's applied, as one comparable value. */ + /** + * What's applied, as one comparable value — the workstream grant included, + * read live: a workstream edited (a server removed, a credential swapped) + * rebuilds the backend at its next turn, as G4 and §8 promise, on backends + * that mount servers themselves too. + */ get fingerprint(): string { - return this.#fingerprint; + const grant = this.#opts.grant?.(); + if (!grant) return this.#fingerprint; + const servers = [...grant.servers].map(([n, s]) => `${n}=${s.credential?.id ?? ""}:${s.credential?.header ?? ""}:${s.oauthSlot ?? ""}`).sort(); + return `${this.#fingerprint}\u0003${grant.workstream ?? ""}\u0001${grant.workdirServers ? 1 : 0}\u0001${servers.join("\u0001")}`; } /** This session's own servers in use, once each: their hub clients are its to let go. */ diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1515e7c2..34f0c090 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -14,7 +14,9 @@ import { grantRefusal, type McpGrant } from "./workstreams/grant.js"; import { OverrideTokens } from "./workstreams/override.js"; import { withSlot } from "./mcp/oauth.js"; import type { FolderReader } from "./workstreams/folder.js"; -import { createSecretStore, type SecretStore } from "./credentials/store.js"; +import { createSecretStore, credentialEnvName, type SecretStore } from "./credentials/store.js"; +import { credentialsSchema } from "./credentials/schema.js"; +import { workstreamsSchema } from "./workstreams/schema.js"; import { registerSecretPaths } from "./secret-paths.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; @@ -37,7 +39,7 @@ import type { HookBus } from "./hooks/bus.js"; import type { Store } from "./store.js"; import { createPushTransport, PushService } from "./push/index.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; -import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; +import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeConfigBlock, writeCredentialValue, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; import { adoptability, adoptableName, adoptedEntry, adoptionRefusal, claudeMcpServers, fromRepoFile, fullUrlRequired, isSameAdoption, shellArg } from "./mcp/adopt.js"; import { readWorkdirServers } from "./mcp/workdir-servers.js"; import { cleanMcpError, commandSummary, MAX_SHOWN_KEYS, quotedVisible, redactMcpUrl, serverProgram, shownLabel, visibleCompact, visibleVerbatim } from "./mcp/redact.js"; @@ -1663,6 +1665,16 @@ mcpHub: this.#mcpHub, return this.#workstreamList(msg, auth); case "workstream.resolve": return this.#workstreamResolve(msg, auth); + case "workstream.upsert": + case "workstream.delete": + case "workstream.settings": + return this.#workstreamEdit(msg, auth); + case "credential.list": + return this.#credentialList(msg, auth); + case "credential.upsert": + case "credential.delete": + case "credential.set_value": + return this.#credentialEdit(msg, auth); case "mcp.oauth.begin": return this.#mcpOAuthBegin(msg, auth); case "mcp.oauth.complete": @@ -3432,6 +3444,134 @@ mcpHub: this.#mcpHub, }; } + /** Who may change workstreams and credentials: settings:write, never a delegated token. */ + #configEditRefusal(requestId: string, auth: AuthContext): DaemonMessage | undefined { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) { + return { type: "response.error", requestId, error: "Missing scope: settings:write", code: "forbidden" }; + } + if (auth.delegationDepth > 0) { + return { type: "response.error", requestId, error: "A delegated token can't change workstreams or credentials", code: "forbidden" }; + } + return undefined; + } + + /** + * Apply a workstreams or credentials block just written to config.json: + * the live service (grants and new sessions follow it at once — live + * sessions keep their homes), this manager's config, and every subscriber. + */ + #applyWorkstreamConfig(next: { workstreams?: unknown; credentials?: unknown }): void { + const workstreams = next.workstreams !== undefined ? workstreamsSchema.parse(next.workstreams) : (this.#config?.workstreams ?? workstreamsSchema.parse(undefined)); + const credentials = next.credentials !== undefined ? credentialsSchema.parse(next.credentials) : (this.#config?.credentials ?? []); + if (this.#config) this.#config = { ...this.#config, workstreams, credentials }; + this.#workstreams.update(workstreams, credentials); + this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + } + + #workstreamEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const asObject = (v: unknown): Record => (v && typeof v === "object" && !Array.isArray(v) ? { ...(v as Record) } : {}); + const written = writeConfigBlock("workstreams", (current) => { + const block = asObject(current); + const items = Array.isArray(block.items) ? [...(block.items as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "workstream.upsert") { + const at = items.findIndex((i) => idOf(i) === msg.item.id); + if (at >= 0) items[at] = msg.item; + else items.push(msg.item); + block.items = items; + } else if (msg.type === "workstream.delete") { + block.items = items.filter((i) => idOf(i) !== msg.workstream); + if (block.default === msg.workstream) delete block.default; + } else { + if (msg.isolateGlobalServers !== undefined) block.isolateGlobalServers = msg.isolateGlobalServers; + if (msg.default === null) delete block.default; + else if (msg.default !== undefined) block.default = msg.default; + } + return block; + }); + if (!written.ok) return { type: "response.error", requestId: msg.id, error: written.error, code: "invalid_request" }; + this.#store.audit(auth.sub, msg.type, "", msg.type === "workstream.upsert" ? `id=${String(msg.item.id)}` : msg.type === "workstream.delete" ? `id=${msg.workstream}` : `isolate=${msg.isolateGlobalServers ?? "-"} default=${msg.default === null ? "none" : (msg.default ?? "-")}`); + this.#applyWorkstreamConfig({ workstreams: written.value }); + return this.#workstreamList({ type: "workstream.list", id: msg.id }, auth); + } + + #credentialList(msg: { id: string }, auth: AuthContext): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_READ)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: settings:read", code: "forbidden" }; + } + const items = this.#config?.workstreams?.items ?? []; + const usedBy = (id: string) => + items + .filter((w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id)) + .map((w) => w.id); + const tenant = tenantOf(auth); + const credentials = (this.#config?.credentials ?? []).map((c) => { + let status: { set: boolean; source?: string }; + if (c.kind === "oauth") { + const spec = this.#mcpRegistry?.get(c.server); + const signedIn = spec && this.#mcpOAuth ? this.#mcpOAuth.signedIn(withSlot(spec, c.id), tenant) : false; + status = signedIn ? { set: true, source: `signed in to ${c.server}` } : { set: false }; + } else { + const s = this.#secrets.status(c.id); + status = s.set ? { set: true, source: s.source } : { set: false }; + } + return { + id: c.id, + kind: c.kind, + ...("header" in c && c.header ? { header: c.header } : {}), + ...("server" in c && c.server ? { server: c.server } : {}), + ...(c.description ? { description: c.description } : {}), + status, + usedBy: usedBy(c.id), + }; + }); + return { type: "credential.list.result", requestId: msg.id, store: { kind: this.#secrets.kind, writable: this.#secrets.writable }, credentials }; + } + + #credentialEdit(msg: Extract, auth: AuthContext): DaemonMessage { + const refused = this.#configEditRefusal(msg.id, auth); + if (refused) return refused; + const err = (error: string): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code: "invalid_request" }); + const declared = this.#config?.credentials ?? []; + if (msg.type === "credential.set_value") { + const decl = declared.find((c) => c.id === msg.credential); + if (!decl) return err(`No credential "${msg.credential}" is declared`); + if (decl.kind === "oauth") return err(`"${decl.id}" is an OAuth sign-in: codeoid mcp signin ${decl.server} --credential ${decl.id}`); + if (!this.#secrets.writable || this.#secrets.kind !== "env-file") return err(`The ${this.#secrets.kind} credential store is managed outside codeoid`); + const w = writeCredentialValue(credentialEnvName(decl.id), msg.value); + if (!w.ok) return err(w.error); + // The value never goes to the audit, only that it changed. + this.#store.audit(auth.sub, "credential.set_value", "", `id=${decl.id} ${msg.value === null ? "cleared" : "set"}`); + } else { + const id = msg.type === "credential.upsert" ? String(msg.item.id ?? "") : msg.credential; + if (msg.type === "credential.delete") { + const users = (this.#config?.workstreams?.items ?? []).filter( + (w) => Object.values(w.env).some((r) => r.secret === id) || w.mcpServers.some((s) => typeof s !== "string" && s.credential === id), + ); + if (users.length > 0) return err(`"${id}" is used by ${users.map((u) => u.id).join(", ")}: remove it there first`); + } + const written = writeConfigBlock("credentials", (current) => { + const list = Array.isArray(current) ? [...(current as unknown[])] : []; + const idOf = (i: unknown) => (i && typeof i === "object" ? (i as { id?: unknown }).id : undefined); + if (msg.type === "credential.upsert") { + const at = list.findIndex((i) => idOf(i) === id); + if (at >= 0) list[at] = msg.item; + else list.push(msg.item); + return list; + } + return list.filter((i) => idOf(i) !== id); + }); + if (!written.ok) return err(written.error); + if (msg.type === "credential.delete" && this.#secrets.writable && this.#secrets.kind === "env-file") writeCredentialValue(credentialEnvName(id), null); + this.#store.audit(auth.sub, msg.type, "", `id=${id}`); + this.#applyWorkstreamConfig({ credentials: written.value }); + } + if (msg.type === "credential.set_value") this.#pushToProviderSubscribers({ type: "workstreams.changed", revision: this.#workstreams.revision }); + return this.#credentialList(msg, auth); + } + /** * What a folder resolves to (and whether an explicit choice conflicts), for * the new-session picker. Gated like a create: it answers the question a diff --git a/src/daemon/settings/store.ts b/src/daemon/settings/store.ts index 59965e70..77b505d2 100644 --- a/src/daemon/settings/store.ts +++ b/src/daemon/settings/store.ts @@ -275,6 +275,48 @@ export function writeWorktreeTrust( return { ok: true, before, after }; } +/** + * Replace one top-level block of config.json (`workstreams`, `credentials`) + * with what `update` returns from the block as the file holds it NOW — so a + * hand edit made since the daemon started is never overwritten — then + * validate the whole config and write it atomically. Nothing is written if it + * doesn't validate; `undefined` from `update` removes the block. Returns the + * block as written. + */ +export function writeConfigBlock( + key: "workstreams" | "credentials", + update: (current: unknown) => unknown, +): { ok: true; value: unknown } | { ok: false; error: string } { + let raw: Record; + try { + raw = readRawConfig(); + } catch (err) { + return { ok: false, error: `config.json is not valid JSON: ${errMsg(err)}` }; + } + const value = update(raw[key]); + if (value === undefined) delete raw[key]; + else raw[key] = value; + const validation = validateConfigObject(raw); + if (!validation.ok) return { ok: false, error: validation.issues.map((i) => `${i.path ? `${i.path}: ` : ""}${i.message}`).join("; ") }; + writeConfigJson(raw); + return { ok: true, value }; +} + +/** + * Set (or with `null`, clear) a named credential's value in the env-file + * secret store: `CODEOID_CRED_` in `.env` (0600) and the daemon's live + * environment, so it applies at the next use without a restart. The value is + * never read back out: callers get presence only (credentials/store.ts). + */ +export function writeCredentialValue(envName: string, value: string | null): { ok: true } | { ok: false; error: string } { + if (!/^CODEOID_CRED_[A-Z0-9_]{1,64}$/.test(envName)) return { ok: false, error: "not a credential's variable" }; + if (value !== null && (value.length === 0 || /[\r\n\0]/.test(value))) return { ok: false, error: "a credential value is one non-empty line" }; + writeDotEnv(new Map([[envName, value]])); + if (value === null) delete process.env[envName]; + else process.env[envName] = value; + return { ok: true }; +} + // ── config.json IO ────────────────────────────────────────────────────────── function readRawConfig(): Record { diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index ba89f5c1..6acc4e37 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -466,6 +466,10 @@ describe("DaemonMessage routing", () => { return `workstream.list:${msg.workstreams.length}`; case "workstream.resolve.result": return `workstream.resolve:${msg.resolution.home ?? "none"}`; + case "workstreams.changed": + return `workstreams.changed:${msg.revision}`; + case "credential.list.result": + return `credential.list:${msg.credentials.length}`; case "session.turns.result": return `turns:${msg.turns.length}`; case "session.approvals.result": diff --git a/src/tests/workstreams-edit.test.ts b/src/tests/workstreams-edit.test.ts new file mode 100644 index 00000000..530a2cb8 --- /dev/null +++ b/src/tests/workstreams-edit.test.ts @@ -0,0 +1,128 @@ +/** + * Editing workstreams and credentials live (W2): written to config.json / + * .env through the settings store (whole config validated, nothing written + * otherwise), applied to the running daemon at once, and announced. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../config.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const OWNER: AuthContext = { sub: "user:edit", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +const pushed: DaemonMessage[] = []; +const client = { id: "c-edit", auth: OWNER, send: (m: DaemonMessage) => void pushed.push(m) }; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +let savedXdg: string | undefined; +let manager: SessionManager; + +function configJson(): Record { + return JSON.parse(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")); +} + +beforeEach(async () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-edit-"))); + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "config"); + mkdirSync(join(tmp, "config", "codeoid"), { recursive: true }); + mkdirSync(join(tmp, "home", "acme"), { recursive: true }); + writeFileSync(join(tmp, "config", "codeoid", "config.json"), JSON.stringify({ dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts") })); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: loadConfig({ env: {}, quiet: true }), + _testProviderFactory: () => new MockSessionProvider(), + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + // Subscribe the client to provider/workstream broadcasts, as a web client does. + await manager.handle({ type: "providers.list", id: "sub" } as never, OWNER, client); + pushed.length = 0; +}); + +afterEach(async () => { + try { + await manager.drain(2_000); + } catch {} + try { + await transcript.flush(); + } catch {} + store.close(); + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + for (const k of Object.keys(process.env)) if (k.startsWith("CODEOID_CRED_")) delete process.env[k]; + rmSync(tmp, { recursive: true, force: true }); +}); + +const h = (msg: Record, auth: AuthContext = OWNER) => manager.handle(msg as never, auth, client); + +describe("editing workstreams", () => { + it("an upsert is validated, written, applied at once and announced; a create there gets it", async () => { + const r = await h({ type: "workstream.upsert", id: "u1", item: { id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } } }); + expect(r).toMatchObject({ type: "workstream.list.result", workstreams: [{ id: "acme", claim: "exclusive" }] }); + expect((configJson().workstreams as { items: unknown[] }).items).toEqual([{ id: "acme", claim: "exclusive", match: { paths: ["~/acme"] } }]); + expect(pushed.some((m) => m.type === "workstreams.changed")).toBe(true); + const made = (await h({ type: "session.create", id: "c1", name: "s", workdir: join(tmp, "home", "acme") })) as { data: SessionInfo }; + expect(made.data.workstream).toMatchObject({ id: "acme", source: "matched" }); + }); + + it("an invalid item writes nothing and says why", async () => { + const before = readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8"); + const r = await h({ type: "workstream.upsert", id: "u2", item: { id: "Bad Id" } }); + expect(r).toMatchObject({ type: "response.error", code: "invalid_request" }); + if (r.type === "response.error") expect(r.error).toContain("workstream id"); + expect(readFileSync(join(tmp, "config", "codeoid", "config.json"), "utf8")).toBe(before); + }); + + it("settings and delete; a deleted default is removed with it", async () => { + await h({ type: "workstream.upsert", id: "u3", item: { id: "acme" } }); + const s = await h({ type: "workstream.settings", id: "u4", isolateGlobalServers: true, default: "acme" }); + expect(s).toMatchObject({ isolateGlobalServers: true, default: "acme" }); + const d = await h({ type: "workstream.delete", id: "u5", workstream: "acme" }); + expect(d).toMatchObject({ type: "workstream.list.result", workstreams: [] }); + expect((d as { default?: string }).default).toBeUndefined(); + }); + + it("needs settings:write on an undelegated token", async () => { + expect(await h({ type: "workstream.upsert", id: "u6", item: { id: "x" } }, { ...OWNER, scopes: [SCOPES.SETTINGS_READ] as AuthContext["scopes"] })).toMatchObject({ code: "forbidden" }); + expect(await h({ type: "workstream.upsert", id: "u7", item: { id: "x" } }, { ...OWNER, delegationDepth: 1 })).toMatchObject({ code: "forbidden" }); + }); +}); + +describe("editing credentials", () => { + it("declare, set a value (never returned), see it used, refuse deleting it while used, then delete", async () => { + const up = await h({ type: "credential.upsert", id: "k1", item: { id: "gh-acme", kind: "env" } }); + expect(up).toMatchObject({ type: "credential.list.result", store: { kind: "env-file", writable: true }, credentials: [{ id: "gh-acme", status: { set: false }, usedBy: [] }] }); + const set = await h({ type: "credential.set_value", id: "k2", credential: "gh-acme", value: "ghp_secret_value" }); + expect(JSON.stringify(set)).not.toContain("ghp_secret_value"); + expect(set).toMatchObject({ credentials: [{ id: "gh-acme", status: { set: true, source: "CODEOID_CRED_GH_ACME" } }] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).toContain("CODEOID_CRED_GH_ACME=ghp_secret_value"); + await h({ type: "workstream.upsert", id: "k3", item: { id: "acme", env: { GH_TOKEN: { secret: "gh-acme" } } } }); + expect(await h({ type: "credential.list", id: "k4" })).toMatchObject({ credentials: [{ usedBy: ["acme"] }] }); + const blocked = await h({ type: "credential.delete", id: "k5", credential: "gh-acme" }); + expect(blocked).toMatchObject({ type: "response.error" }); + if (blocked.type === "response.error") expect(blocked.error).toContain("used by acme"); + await h({ type: "workstream.delete", id: "k6", workstream: "acme" }); + expect(await h({ type: "credential.delete", id: "k7", credential: "gh-acme" })).toMatchObject({ credentials: [] }); + expect(readFileSync(join(tmp, "config", "codeoid", ".env"), "utf8")).not.toContain("CODEOID_CRED_GH_ACME"); + }); + + it("a multi-line value, an undeclared one, or an OAuth one's value is refused", async () => { + await h({ type: "credential.upsert", id: "m1", item: { id: "e", kind: "env" } }); + await h({ type: "credential.upsert", id: "m2", item: { id: "o", kind: "oauth", server: "slack" } }); + expect(await h({ type: "credential.set_value", id: "m3", credential: "e", value: "a\nb" })).toMatchObject({ type: "response.error" }); + expect(await h({ type: "credential.set_value", id: "m4", credential: "nope", value: "x" })).toMatchObject({ type: "response.error" }); + const o = await h({ type: "credential.set_value", id: "m5", credential: "o", value: "x" }); + if (o.type === "response.error") expect(o.error).toContain("codeoid mcp signin slack --credential o"); + }); +}); From c1d9b02caf2cb9f38830b8e1971410b94a0e3929 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 11 Nov 2026 14:05:56 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20workstreams=20W2=20=E2=80=94=20the?= =?UTF-8?q?=20web=20UI:=20Workstreams=20and=20Credentials=20in=20Settings,?= =?UTF-8?q?=20a=20picker=20and=20override=20confirm=20in=20New=20session,?= =?UTF-8?q?=20a=20chip=20on=20every=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Workstreams: isolation switch, default, the list (claim, share, rules, servers, env), an editor that keeps what it doesn't show (the item as configured, not bases merged — WorkstreamSummary.config), remove, and 'check a folder' - Settings → Credentials: declare by id and kind, set a value from a password field (sent once, cleared, never shown back), clear, remove (refused while used), OAuth slots point at codeoid mcp signin - New session: what the folder resolves to and why, an explicit choice; a choice past an exclusive folder is offered with what it would change, and Start anyway resends with the override token - a workstream chip in the session header and the session list (orphaned red, override amber); the list filter matches workstream names - live: workstreams.changed refetches what's shown Co-Authored-By: Claude Opus 5.5 --- packages/protocol/src/workstreams.ts | 2 + src/daemon/workstreams/service.ts | 1 + web/src/components/CenterPane.tsx | 2 + web/src/components/CredentialsPanel.tsx | 208 +++++++++++ web/src/components/NewSessionModal.tsx | 108 +++++- .../NewSessionModal.workstreams.test.tsx | 80 +++++ web/src/components/SessionListPane.tsx | 4 + web/src/components/SettingsDrawer.tsx | 10 + web/src/components/WorkstreamChip.test.tsx | 39 ++ web/src/components/WorkstreamChip.tsx | 51 +++ web/src/components/WorkstreamsPanel.tsx | 337 ++++++++++++++++++ web/src/components/WorkstreamsPanels.test.tsx | 91 +++++ web/src/lib/fleet.ts | 4 +- web/src/lib/workstream-form.test.ts | 33 ++ web/src/lib/workstream-form.ts | 117 ++++++ web/src/state/connection.ts | 7 + web/src/state/workstreams.ts | 163 +++++++++ 17 files changed, 1254 insertions(+), 3 deletions(-) create mode 100644 web/src/components/CredentialsPanel.tsx create mode 100644 web/src/components/NewSessionModal.workstreams.test.tsx create mode 100644 web/src/components/WorkstreamChip.test.tsx create mode 100644 web/src/components/WorkstreamChip.tsx create mode 100644 web/src/components/WorkstreamsPanel.tsx create mode 100644 web/src/components/WorkstreamsPanels.test.tsx create mode 100644 web/src/lib/workstream-form.test.ts create mode 100644 web/src/lib/workstream-form.ts create mode 100644 web/src/state/workstreams.ts diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts index dd03374c..3708a26f 100644 --- a/packages/protocol/src/workstreams.ts +++ b/packages/protocol/src/workstreams.ts @@ -63,6 +63,8 @@ export interface WorkstreamSummary { env: string[]; git?: { name?: string; email?: string }; skillDirs: string[]; + /** The item as configured (bases NOT merged): what an editor edits and sends back to `workstream.upsert`. */ + config: Record; } export interface WorkstreamMatchInfo { diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts index 92f349d3..e0c6dfc8 100644 --- a/src/daemon/workstreams/service.ts +++ b/src/daemon/workstreams/service.ts @@ -181,6 +181,7 @@ export class WorkstreamService { env: Object.keys(w.env), ...(w.git ? { git: { ...w.git } } : {}), skillDirs: [...w.skillDirs], + config: structuredClone((this.#cfg.items.find((i) => i.id === w.id) ?? {}) as Record), })); } diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx index 06dca1e4..7eec877b 100644 --- a/web/src/components/CenterPane.tsx +++ b/web/src/components/CenterPane.tsx @@ -25,6 +25,7 @@ import PromptBox from "./prompt/PromptBox"; import SessionControls from "./SessionControls"; import { AccountChip } from "./AccountChip"; import { SandboxChip } from "./SandboxChip"; +import { WorkstreamChip } from "./WorkstreamChip"; import Transcript from "./transcript/Transcript"; import FleetRail from "./FleetRail"; import WorkerIndicator from "./transcript/WorkerIndicator"; @@ -119,6 +120,7 @@ const SessionHeader: Component = () => ( {s().model} + diff --git a/web/src/components/CredentialsPanel.tsx b/web/src/components/CredentialsPanel.tsx new file mode 100644 index 00000000..82c97be2 --- /dev/null +++ b/web/src/components/CredentialsPanel.tsx @@ -0,0 +1,208 @@ +/** + * Settings → Credentials (docs/workstreams-design.md G2): named credentials + * workstreams use — declared by id and kind; values set here (into the + * daemon's env-file store) or managed outside codeoid (a `dir` store, an + * OAuth sign-in). A value is typed into a password field, sent once and + * cleared; the daemon only ever says whether one is set. + */ + +import { type Component, For, Show, createSignal, onMount } from "solid-js"; + +import type { CredentialInfo } from "../protocol/types"; +import { credentialsState, deleteCredential, fetchCredentials, saveCredential, setCredentialValue } from "../state/workstreams"; + +const label = "text-[11px] font-medium uppercase tracking-wider text-fg-faint"; +const input = + "w-full rounded border border-border bg-bg px-3 py-1.5 font-mono text-sm text-fg focus:border-accent focus:outline-none"; +const button = + "rounded border border-border bg-bg px-2.5 py-1 text-[12px] text-fg-muted transition hover:border-accent/40 hover:text-fg disabled:opacity-50"; + +const KINDS: Array<{ kind: CredentialInfo["kind"]; what: string }> = [ + { kind: "env", what: "an env var a workstream sets (GH_TOKEN…)" }, + { kind: "bearer", what: "sent to an MCP server as Authorization: Bearer" }, + { kind: "header", what: "sent to an MCP server in a named header" }, + { kind: "oauth", what: "a second OAuth sign-in to one server" }, +]; + +export const CredentialsPanel: Component = () => { + onMount(() => void fetchCredentials()); + const [busy, setBusy] = createSignal(null); + const [error, setError] = createSignal(null); + const [adding, setAdding] = createSignal(false); + const run = async (key: string, fn: () => Promise) => { + setBusy(key); + setError(null); + try { + await fn(); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + setBusy(null); + } + }; + const store = () => credentialsState().store; + + return ( +
+
+

Credentials

+

+ Secrets workstreams use, by name. A value never leaves the daemon for an agent: it's read when a server is called or a + session starts. Values live in the {store().kind} store + {store().writable ? "." : ", managed outside codeoid."} +

+
+ + + +
+ +
None declared yet.
+
+ + {(c) => } + +
+ setAdding(true)}> + Add credential + + } + > + setAdding(false)} run={run} busy={busy()} /> + +
+ ); +}; + +const CredentialRow: Component<{ + c: CredentialInfo; + writable: boolean; + run: (key: string, fn: () => Promise) => Promise; + busy: string | null; +}> = (props) => { + const [value, setValue] = createSignal(""); + const [confirm, setConfirm] = createSignal(false); + const c = () => props.c; + return ( +
+
+ {c().id} + {c().kind} + + {c().header} + + + for {c().server} + + {c().status.set ? `set (${c().status.source})` : "not set"} + 0}> + used by {c().usedBy.join(", ")} + + + setConfirm(true)} disabled={props.busy !== null}> + Remove + + } + > + + + +
+ + Sign in with codeoid mcp signin {c().server} --credential {c().id} +
+ } + > + +
{ + e.preventDefault(); + void props.run(`set:${c().id}`, () => setCredentialValue(c().id, value())).then((ok) => ok && setValue("")); + }} + > + setValue(e.currentTarget.value)} + /> + + + + +
+
+ + + ); +}; + +const AddCredential: Component<{ onDone: () => void; run: (key: string, fn: () => Promise) => Promise; busy: string | null }> = (props) => { + const [id, setId] = createSignal(""); + const [kind, setKind] = createSignal("env"); + const [extra, setExtra] = createSignal(""); + const save = async () => { + const item: Record = { id: id().trim(), kind: kind() }; + if (kind() === "header") item.header = extra().trim(); + if (kind() === "oauth") item.server = extra().trim(); + if (await props.run("add", () => saveCredential(item))) props.onDone(); + }; + return ( +
{ + e.preventDefault(); + void save(); + }} + > + + + + + +
+ + +
+
+ ); +}; diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 561f9773..049e7b2e 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -59,6 +59,8 @@ function sandboxDefaultLabel(d: SandboxProvidersData | null): string { import DirectoryPicker from "./files/DirectoryPicker"; import { providerIds, providerLabel } from "../state/providers"; import { accountDetail, backendAccounts, DEFAULT_ACCOUNT, fetchAccounts } from "../state/accounts"; +import { fetchWorkstreams, homeChoices, resolveWorkstream } from "../state/workstreams"; +import type { WorkstreamResolution } from "../protocol/types"; /** The modal serves three flows from one dialog: a plain session, a governed * pipeline run (docs/pipeline-run.md — adds a goal box + requires a pack), or @@ -162,6 +164,31 @@ const NewSessionModal: Component = () => { // daemon default, "default" = pin the daemon's own login, else a named // account (accounts.ts). Names are per backend, so a provider change resets it. const [account, setAccount] = createSignal(""); + // Home workstream (docs/workstreams-design.md): "" = the folder's (what + // `workstream.resolve` says), else an explicit choice. + const [workstream, setWorkstream] = createSignal(""); + const [resolution, setResolution] = createSignal(null); + // A choice refused past another workstream's exclusive claim, with the token + // that confirms it (G3): shown with what would change, never sent unasked. + const [overrideOffer, setOverrideOffer] = createSignal | null>(null); + // What the folder resolves to, as it is typed or picked (debounced). + createEffect( + on([workdir, openSignal, () => homeChoices().length], ([dir, open, homes]) => { + setOverrideOffer(null); + if (!open || homes === 0 || !dir.trim()) { + setResolution(null); + return; + } + const timer = setTimeout(() => { + resolveWorkstream(dir.trim()) + .then((r) => { + if (workdir() === dir) setResolution(r); + }) + .catch(() => setResolution(null)); + }, 300); + onCleanup(() => clearTimeout(timer)); + }), + ); // OS sandbox (#448): "" = the daemon's default; else auto | off | a provider id. const [sandboxChoice, setSandboxChoice] = createSignal(""); // "" = the daemon's default network; else on | off. @@ -421,6 +448,8 @@ const NewSessionModal: Component = () => { void fetchPacks().catch(() => {}); // Named accounts, for the account picker (hidden without any). void fetchAccounts(true).catch(() => {}); + // Workstreams, for the workstream picker (hidden without any). + void fetchWorkstreams(true).catch(() => {}); requestAnimationFrame(() => nameRef?.focus()); } }), @@ -454,6 +483,9 @@ const NewSessionModal: Component = () => { setGoal(""); setProviderId(""); setAccount(""); + setWorkstream(""); + setResolution(null); + setOverrideOffer(null); setPackId(""); setPackRole(""); setSandboxChoice(""); @@ -464,7 +496,7 @@ const NewSessionModal: Component = () => { setWarnings([]); } - async function submit(ev: Event): Promise { + async function submit(ev: Event, overrideToken?: string): Promise { ev.preventDefault(); if (busy()) return; const n = name().trim(); @@ -597,6 +629,8 @@ const NewSessionModal: Component = () => { : {}), ...(packId() ? { pack: packId() } : {}), ...(packRole() ? { packRole: packRole() } : {}), + ...(workstream() ? { workstream: workstream() } : {}), + ...(overrideToken && workstream() ? { override: { token: overrideToken } } : {}), ...(sandboxChoice() || sandboxNetwork() || sandboxDocker() || sandboxRoots().trim() || sandboxAdded().trim() ? { sandbox: { @@ -628,7 +662,17 @@ const NewSessionModal: Component = () => { closeAndReset(); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + // Past another workstream's exclusive claim, with a way to confirm it: + // offered, never sent unasked. + const detail = err && typeof err === "object" && "detail" in err ? (err as { detail?: { kind?: string; resolution?: WorkstreamResolution } }).detail : undefined; + const conflict = detail?.kind === "workstream_conflict" ? detail.resolution?.conflict : undefined; + if (conflict?.overrideToken && !overrideToken) { + setOverrideOffer(conflict); + setError(null); + } else { + setOverrideOffer(null); + setError(err instanceof Error ? err.message : String(err)); + } setBusy(false); } } @@ -1166,6 +1210,66 @@ const NewSessionModal: Component = () => { + 0}> + + + + + {(c) => ( + + )} + +
{error()} diff --git a/web/src/components/NewSessionModal.workstreams.test.tsx b/web/src/components/NewSessionModal.workstreams.test.tsx new file mode 100644 index 00000000..ed411012 --- /dev/null +++ b/web/src/components/NewSessionModal.workstreams.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, waitFor } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const requestMock = vi.hoisted(() => vi.fn()); +vi.mock("../state/connection", () => ({ + send: vi.fn(), + request: requestMock, + newRequestId: () => "r", + refreshSessions: vi.fn(() => Promise.resolve([])), + authIdentity: vi.fn(), + getClient: () => { + throw new Error("not bootstrapped"); + }, +})); +vi.mock("../state/packs", () => ({ + fetchPacks: vi.fn(() => Promise.resolve()), + packsState: () => ({ installed: [], available: [], registries: [], loading: false, busy: false, loaded: true, error: null }), +})); +vi.mock("../state/pipelines", () => ({ runPipeline: vi.fn() })); +const resolveMock = vi.hoisted(() => vi.fn()); +vi.mock("../state/workstreams", () => ({ + fetchWorkstreams: vi.fn(() => Promise.resolve()), + homeChoices: () => [ + { id: "acme", name: "Acme", abstract: false }, + { id: "mine", name: "mine", abstract: false }, + ], + resolveWorkstream: resolveMock, +})); + +import NewSessionModal, { openNewSessionModal } from "./NewSessionModal"; + +afterEach(() => { + cleanup(); + requestMock.mockReset(); + resolveMock.mockReset(); +}); + +function setup() { + resolveMock.mockResolvedValue({ home: "acme", source: "matched", matchedBy: "~/acme", candidates: [] }); + const r = render(() => ); + openNewSessionModal(); + return r; +} + +describe("the new-session dialog's workstream", () => { + it("says what the folder resolves to, and sends an explicit choice", async () => { + const r = setup(); + fireEvent.input(r.getByLabelText(/^Name/), { target: { value: "s1" } }); + fireEvent.input(r.getByLabelText(/^Workdir/), { target: { value: "/w/acme" } }); + await waitFor(() => expect(r.getByLabelText("Workstream").textContent).toContain("The folder's: acme (matched by ~/acme)"), { timeout: 2000 }); + fireEvent.change(r.getByLabelText("Workstream"), { target: { value: "mine" } }); + requestMock.mockResolvedValueOnce({ info: { id: "s1", name: "s1" } }); + fireEvent.click(r.getByRole("button", { name: /^create/i })); + await waitFor(() => expect(requestMock).toHaveBeenCalled()); + expect(requestMock.mock.calls[0][0]).toMatchObject({ type: "session.create", workstream: "mine" }); + expect(requestMock.mock.calls[0][0]).not.toHaveProperty("override"); + }); + + it("a conflict is offered with what would change; Start anyway resends with the token", async () => { + const r = setup(); + fireEvent.input(r.getByLabelText(/^Name/), { target: { value: "s2" } }); + fireEvent.input(r.getByLabelText(/^Workdir/), { target: { value: "/w/acme" } }); + fireEvent.change(r.getByLabelText("Workstream"), { target: { value: "mine" } }); + const conflict = Object.assign(new Error('This folder belongs to workstream "acme" (exclusive).'), { + code: "invalid_request", + detail: { kind: "workstream_conflict", resolution: { home: "mine", source: "explicit", candidates: [], conflict: { folder: "acme", chosen: "mine", overridable: true, overrideToken: "tok-1", changes: { gains: ["notes"], withholds: ["github"], env: [] } } } }, + }); + requestMock.mockRejectedValueOnce(conflict); + fireEvent.click(r.getByRole("button", { name: /^create/i })); + const offer = await r.findByRole("alert"); + expect(offer.textContent).toContain("belongs to workstream “acme” (exclusive)"); + expect(offer.textContent).toContain("its servers (notes)"); + expect(offer.textContent).toContain("not given github"); + requestMock.mockResolvedValueOnce({ info: { id: "s2", name: "s2" } }); + fireEvent.click(r.getByRole("button", { name: "Start anyway" })); + await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2)); + expect(requestMock.mock.calls[1][0]).toMatchObject({ workstream: "mine", override: { token: "tok-1" } }); + }); +}); diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx index 53ba460d..9164d36b 100644 --- a/web/src/components/SessionListPane.tsx +++ b/web/src/components/SessionListPane.tsx @@ -6,6 +6,7 @@ */ import { Component, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; +import { WorkstreamChip } from "./WorkstreamChip"; import { formatSessionCost, formatTokens, relativeTime } from "../lib/format"; import { @@ -457,6 +458,9 @@ const SessionRow: Component<{ + + + {(shape) => } diff --git a/web/src/components/SettingsDrawer.tsx b/web/src/components/SettingsDrawer.tsx index e9080eb5..3008c733 100644 --- a/web/src/components/SettingsDrawer.tsx +++ b/web/src/components/SettingsDrawer.tsx @@ -41,6 +41,8 @@ import type { } from "../protocol/types"; import { AccountsPanel, focusAccountsBackend } from "./AccountsPanel"; import { EndpointsPanel } from "./EndpointsPanel"; +import { WorkstreamsPanel } from "./WorkstreamsPanel"; +import { CredentialsPanel } from "./CredentialsPanel"; /** * A backend tab whose id names a backend whose OWN (daemon-wide) login codeoid @@ -66,6 +68,8 @@ const [activeTab, setActiveTab] = createSignal(""); /** Tabs that render their own panel instead of manifest fields. */ const SYNTHETIC_TABS = [ { id: "accounts", icon: "⚿", title: "Accounts" }, + { id: "workstreams", icon: "◈", title: "Workstreams" }, + { id: "credentials", icon: "⚷", title: "Credentials" }, { id: "endpoints", icon: "⇄", title: "OpenAI endpoints" }, ] as const; const isSyntheticTab = (id: string) => SYNTHETIC_TABS.some((t) => t.id === id); @@ -237,6 +241,12 @@ const SettingsDrawer: Component = () => { + + + + + + diff --git a/web/src/components/WorkstreamChip.test.tsx b/web/src/components/WorkstreamChip.test.tsx new file mode 100644 index 00000000..7d248109 --- /dev/null +++ b/web/src/components/WorkstreamChip.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { cleanup, render } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { SessionInfo } from "../protocol/types"; +import { chipColor, WorkstreamChip } from "./WorkstreamChip"; + +const session = (workstream?: SessionInfo["workstream"]) => ({ id: "s", name: "s", workdir: "/w", status: "idle", createdBy: "u", createdAt: "t", attachedClients: 0, ...(workstream ? { workstream } : {}) }) as SessionInfo; + +afterEach(() => cleanup()); + +describe("WorkstreamChip", () => { + it("shows the name, how it was decided, and nothing on a daemon without workstreams", () => { + const { getByTestId, unmount } = render(() => ); + expect(getByTestId("workstream-chip").textContent).toContain("Acme"); + expect(getByTestId("workstream-chip").getAttribute("title")).toContain("matched by ~/acme"); + unmount(); + const none = render(() => ); + expect(none.container.textContent).toBe(""); + }); + + it("orphaned is red, an override amber, no home says so", () => { + const o = render(() => ); + expect(o.getByTestId("workstream-chip").className).toContain("text-danger"); + cleanup(); + const v = render(() => ); + expect(v.getByTestId("workstream-chip").className).toContain("text-warn"); + cleanup(); + const n = render(() => ); + expect(n.getByTestId("workstream-chip").textContent).toContain("no workstream"); + }); + + it("a config color is a name or #hex only — never arbitrary CSS", () => { + expect(chipColor("orange")).toBe("orange"); + expect(chipColor("#f97316")).toBe("#f97316"); + expect(chipColor("red; background:url(x)")).toBeUndefined(); + expect(chipColor(undefined)).toBeUndefined(); + }); +}); diff --git a/web/src/components/WorkstreamChip.tsx b/web/src/components/WorkstreamChip.tsx new file mode 100644 index 00000000..72f8191c --- /dev/null +++ b/web/src/components/WorkstreamChip.tsx @@ -0,0 +1,51 @@ +/** + * A session's home workstream (docs/workstreams-design.md), as a chip: its + * name in its color, amber when homed past another workstream's claim + * (override), red when its workstream was removed (orphaned), and "no + * workstream" when nothing matched. Absent on a daemon without workstreams. + */ + +import { type Component, Show } from "solid-js"; + +import type { SessionInfo } from "../protocol/types"; + +/** A config color as a CSS color, or none: a name or a #hex only — never arbitrary CSS. */ +export function chipColor(color: string | undefined): string | undefined { + if (!color) return undefined; + return /^#[0-9a-fA-F]{3,8}$/.test(color) || /^[a-zA-Z]{3,20}$/.test(color) ? color : undefined; +} + +export const WorkstreamChip: Component<{ session: SessionInfo }> = (props) => { + const ws = () => props.session.workstream; + const tone = () => { + const w = ws(); + if (w?.orphaned) return "border-danger/50 bg-danger/10 text-danger"; + if (w?.source === "override") return "border-warn/50 bg-warn/10 text-warn"; + if (!w?.id) return "border-border bg-bg text-fg-faint"; + return "border-border bg-bg text-fg-muted"; + }; + const title = () => { + const w = ws(); + if (!w) return ""; + if (w.orphaned) return `Workstream "${w.id}" is no longer configured: move this session to another`; + if (!w.id) return "No workstream: no rule matched this folder and no default is set"; + const how = + w.source === "matched" ? `matched by ${w.matchedBy ?? "a rule"}` + : w.source === "default" ? "the default workstream" + : w.source === "explicit" ? "chosen at create" + : w.source === "override" ? "chosen past the folder's own workstream" + : w.source === "migrated" ? "from its folder, when workstreams were set up" + : w.source; + return `Workstream ${w.name ?? w.id} — ${how}`; + }; + return ( + + + + {(c) => + {ws()?.id ? (ws()?.name ?? ws()?.id) : "no workstream"} + + + ); +}; diff --git a/web/src/components/WorkstreamsPanel.tsx b/web/src/components/WorkstreamsPanel.tsx new file mode 100644 index 00000000..ea3f59b1 --- /dev/null +++ b/web/src/components/WorkstreamsPanel.tsx @@ -0,0 +1,337 @@ +/** + * Settings → Workstreams (docs/workstreams-design.md): named lines of work — + * which folders are whose, and what their sessions get. Edits go to the daemon + * whole and are validated there (one rule, one message); the list it answers + * with replaces this one. + */ + +import { type Component, For, Show, createSignal, onMount } from "solid-js"; + +import { EMPTY_FORM, fromForm, toForm, type WorkstreamForm } from "../lib/workstream-form"; +import type { WorkstreamResolution, WorkstreamSummary } from "../protocol/types"; +import { + deleteWorkstream, + fetchWorkstreams, + resolveWorkstream, + saveWorkstream, + saveWorkstreamSettings, + workstreamsState, +} from "../state/workstreams"; +import { chipColor } from "./WorkstreamChip"; + +const label = "text-[11px] font-medium uppercase tracking-wider text-fg-faint"; +const input = + "w-full rounded border border-border bg-bg px-3 py-1.5 font-mono text-sm text-fg focus:border-accent focus:outline-none"; +const button = + "rounded border border-border bg-bg px-2.5 py-1 text-[12px] text-fg-muted transition hover:border-accent/40 hover:text-fg disabled:opacity-50"; + +export const WorkstreamsPanel: Component = () => { + onMount(() => void fetchWorkstreams(true)); + const [busy, setBusy] = createSignal(null); + const [error, setError] = createSignal(null); + /** The workstream being edited: its id, or "" for a new one; null = none. */ + const [editing, setEditing] = createSignal(null); + const [confirmRemove, setConfirmRemove] = createSignal(null); + + const run = async (key: string, fn: () => Promise) => { + setBusy(key); + setError(null); + try { + await fn(); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + setBusy(null); + } + }; + + const list = () => workstreamsState().workstreams; + const homes = () => list().filter((w) => !w.abstract); + + return ( +
+
+

Workstreams

+

+ Named lines of work — a company, a client, a project, a personal setup. Each session belongs to one, by its folder or by + choice, and keeps it. With isolation on, a session reaches only its workstream's MCP servers and credentials. +

+
+ + +
{workstreamsState().error}
+
+ + + + +
+ +
+ Default + +
+
+ +
+ +
+ None yet. Add one below, or run codeoid workstream import for a proposal from your repositories. +
+
+ + {(w) => ( +
+
+ + {(c) => + {w.name} + {w.id} + {w.abstract ? "base" : w.claim} + share: {w.share} + + default + + + + setConfirmRemove(w.id)} disabled={busy() !== null}> + Remove + + } + > + + + +
+
+ {[...w.match.paths, ...w.match.remotes.map((r) => `remote ${r}`)].join(" · ") || (w.abstract ? "a base for others to extend" : "no rules — chosen only")} + 0}> · servers: {w.servers.map((s) => s.server).join(", ")} + 0}> · env: {w.env.join(", ")} +
+ + setEditing(null)} run={run} busy={busy()} /> + +
+ )} +
+
+ + setEditing("")} disabled={busy() !== null}> + Add workstream + + } + > +
+ setEditing(null)} run={run} busy={busy()} /> +
+
+ + +
+ ); +}; + +const Badge: Component<{ children: unknown; accent?: boolean }> = (props) => ( + + {props.children as string} + +); + +/** Edit one workstream (or add one): the fields people set; the rest of the item is kept as configured. */ +const Editor: Component<{ + existing?: WorkstreamSummary; + onDone: () => void; + run: (key: string, fn: () => Promise) => Promise; + busy: string | null; +}> = (props) => { + // eslint-disable-next-line solid/reactivity -- seeded once; the editor is remounted per workstream + const [form, setForm] = createSignal(props.existing ? toForm(props.existing.config) : EMPTY_FORM); + const field = (k: K) => ({ + value: form()[k] as string, + onInput: (e: InputEvent & { currentTarget: HTMLInputElement | HTMLTextAreaElement }) => setForm({ ...form(), [k]: e.currentTarget.value }), + }); + const save = async () => { + const ok = await props.run("save", () => saveWorkstream(fromForm(form(), props.existing?.config))); + if (ok) props.onDone(); + }; + const row = "grid gap-1"; + return ( +
{ + e.preventDefault(); + void save(); + }} + aria-label={props.existing ? `Edit ${props.existing.id}` : "New workstream"} + > + + +