Files
open-design/apps/web/src/state/projects.ts

2932 lines
100 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Project / conversation / message / tab persistence — backed by the
// daemon's SQLite store. All writes round-trip through HTTP so projects
// stay coherent across multiple browser tabs and across restarts.
//
// Most helpers fail soft (returning null / [] on transport errors) so the UI
// can stay rendered when the daemon is briefly unreachable. Reads whose empty
// result changes behavior must preserve failure as a typed error instead.
import { coalescedGet, evictCoalescedGet } from '../lib/coalesced-get';
import { isDaemonProxyConnectionFailure } from '../runtime/daemon-proxy-failure';
import { BackoffController, type BackoffOptions } from '../lib/backoff';
import { markProjectCreatedByViewer } from '../collab/useProjectCollab';
import {
API_ERROR_CODES,
isRetiredHyperFramesPlugin,
isSameWorkspacePrincipal,
type ApiErrorCode,
type ProjectDeleteShareResidual,
} from '@open-design/contracts';
import type {
AppliedPluginSnapshot,
ApplyResult,
ChatSessionMode,
CollabProjectBootstrapResponse,
CreateConversationRequest,
CreateDesignSystemProjectFromProjectResponse,
CreateProjectExampleReference,
DuplicateProjectResponse,
CreatePluginShareProjectResponse,
CreateTerminalRequest,
ImportFolderRequest,
ImportFolderResponse,
InstalledPluginRecord,
LocalCatalogScope,
PluginDuplicateProjectResponse,
PluginInstallOutcome,
PluginShareAction,
ProjectPluginFolderInstallRequest,
ProjectScenarioTaskProfile,
ProjectVisibility,
ProjectWorkspaceScopeResponse,
TerminalSession,
WorkspaceCollabContext,
WorkspaceProjectSummary,
WorkspaceProjectsResponse,
} from '@open-design/contracts';
import { randomUUID } from '../utils/uuid';
import { markProjectDisplaySnapshotsDirty } from './project-display-cache';
import {
workspaceIdentityCacheKey,
workspaceProjectHeaders,
workspaceResourceUrl,
} from '../collab/workspace-identity';
import {
currentWorkspaceAccountGeneration,
currentWorkspaceContextRequestToken,
} from '../collab/useWorkspaceContext';
import type { WorkspaceResourceReadIdentity } from '../collab/workspace-identity';
import type {
ChatMessage,
Conversation,
OpenTabsState,
Project,
ProjectMetadata,
ProjectTemplate,
} from '../types';
import { removeDesignBrowserProjectCache } from '../components/design-browser-storage';
import { boundedRequestErrorCode } from '../analytics/workspace';
export type { PluginInstallOutcome } from '@open-design/contracts';
export type { PluginShareAction } from '@open-design/contracts';
export { workspaceProjectHeaders } from '../collab/workspace-identity';
export type WorkspaceProjectListView = 'all' | 'recent' | 'drafts' | 'team';
const WORKSPACE_PROJECT_LIST_VIEWS: readonly WorkspaceProjectListView[] = [
'all',
'recent',
'drafts',
'team',
];
function workspaceProjectListCacheKey(
context: WorkspaceCollabContext,
workspaceView: WorkspaceProjectListView,
): string {
return [
'workspace-projects',
workspaceIdentityCacheKey(context),
workspaceView,
].join(':');
}
export function invalidateWorkspaceProjectLists(
context: WorkspaceCollabContext,
accountGeneration?: number,
): void {
for (const workspaceView of WORKSPACE_PROJECT_LIST_VIEWS) {
evictCoalescedGet(workspaceProjectListCacheKey(context, workspaceView));
}
markProjectDisplaySnapshotsDirty({ context, accountGeneration });
}
export type WorkspaceContextForWrite = {
context: WorkspaceCollabContext | null;
loading: boolean;
identityChangePending?: boolean;
failure?: 'unsupported' | 'unavailable' | 'reauth-required';
/**
* The directory-verified identity the retained `context` was resolved under,
* carrying the generation token it belongs to. Present on production state
* (`useWorkspaceContext`) whenever a last-good context is held. Used by
* {@link resolvedWorkspaceContextForWrite} to decide whether that retained
* context still belongs to the CURRENT identity generation.
*/
resourceReadIdentity?: WorkspaceResourceReadIdentity | null;
};
/**
* Whether the retained `context` still belongs to the current identity
* generation — the signal that lets a write proceed on a last-good context
* during a transient outage without ever letting one account's cached context
* authorize a write after the identity changed.
*
* True only when the state carries a directory-verified `resourceReadIdentity`
* whose generation matches the LIVE request token AND whose context is the same
* identity as `state.context`. A snapshot from a retired generation (an account
* switch bumped the token) fails this check even if `identityChangePending` in
* the snapshot has not caught up.
*/
function writeContextBelongsToCurrentGeneration(state: WorkspaceContextForWrite): boolean {
const identity = state.resourceReadIdentity;
if (!identity || state.context === null) return false;
if (identity.generation !== currentWorkspaceContextRequestToken()) return false;
return workspaceIdentityCacheKey(identity.context) === workspaceIdentityCacheKey(state.context);
}
export type WorkspaceContextWriteResolutionOptions = {
/**
* `unscoped` is reserved for callers whose operation is genuinely local and
* does not require AMR Workspace authority. All Workspace-owned writes keep
* the default `reject` policy.
*/
unavailablePolicy?: 'reject' | 'unscoped';
};
/**
* Preserve headerless old-daemon/anonymous compatibility, but never collapse
* an unresolved or unavailable modern workspace authority into "anonymous".
*
* When the read is momentarily blocked (loading, a pending identity change, or
* a transient `unavailable` outage) but the shell still holds a directory-
* verified last-good context that belongs to the CURRENT identity generation,
* that context is honored instead of throwing. Remote mutation routes still
* re-verify authority at their network boundary; ordinary `POST /api/projects`
* is deliberately local-first and no longer uses this helper or performs that
* verification. A context from a RETIRED generation (an account switch bumped
* the token) still fails closed — that is the cross-account guard.
*/
export function resolvedWorkspaceContextForWrite(
state: WorkspaceContextForWrite,
options: WorkspaceContextWriteResolutionOptions = {},
): WorkspaceCollabContext | null {
if (
state.loading
|| state.identityChangePending === true
|| state.failure === 'unavailable'
|| state.failure === 'reauth-required'
) {
if (writeContextBelongsToCurrentGeneration(state)) return state.context;
if (options.unavailablePolicy === 'unscoped') return null;
throw new Error('Workspace context is unavailable. Try again when workspace sync finishes.');
}
return state.context;
}
/**
* A workspace project move the daemon refused, carrying the contract error
* code (`ApiError.code`) when the response body names one. Kept as a named
* class so UI callers can branch on `code` — `TEAM_PROJECT_OWNER_CONFLICT`
* is a permanent ownership conflict that must not render as the generic
* "try again later" hint.
*/
export class WorkspaceProjectMoveError extends Error {
readonly code: ApiErrorCode | null;
constructor(message: string, code: ApiErrorCode | null) {
super(message);
this.name = 'WorkspaceProjectMoveError';
this.code = code;
}
}
/** A refused project delete with the daemon's stable status/code preserved. */
export class ProjectDeleteError extends Error {
constructor(
message: string,
readonly status: number | undefined,
readonly code: string | undefined,
) {
super(message);
this.name = 'ProjectDeleteError';
}
}
/**
* The contract error code a failed move carries, or null. Duck-typed on a
* string `code` property (validated against `API_ERROR_CODES`) instead of
* `instanceof`, so callers and tests can classify any conforming error
* without importing the concrete class.
*/
export function workspaceProjectMoveErrorCode(error: unknown): ApiErrorCode | null {
if (!(error instanceof Error)) return null;
const code = (error as { code?: unknown }).code;
return typeof code === 'string' && (API_ERROR_CODES as readonly string[]).includes(code)
? (code as ApiErrorCode)
: null;
}
export async function moveWorkspaceProject(input: {
projectId: string;
visibility: ProjectVisibility;
workspaceContext: WorkspaceCollabContext | null;
}): Promise<WorkspaceProjectSummary> {
const context = input.workspaceContext;
if (!context) throw new Error('Workspace context is required');
const resp = await fetch(
`/api/workspaces/${encodeURIComponent(context.workspaceId)}/projects/${encodeURIComponent(input.projectId)}/move`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...workspaceProjectHeaders(context),
},
body: JSON.stringify({ visibility: input.visibility }),
},
);
if (!resp.ok) {
let message = `workspace project move failed with status ${resp.status}`;
let code: ApiErrorCode | null = null;
try {
const body = await resp.json() as { error?: unknown };
if (body.error && typeof body.error === 'object') {
const errorBody = body.error as { code?: unknown; message?: unknown };
if (
typeof errorBody.code === 'string' &&
(API_ERROR_CODES as readonly string[]).includes(errorBody.code)
) {
code = errorBody.code as ApiErrorCode;
}
if (typeof errorBody.message === 'string' && errorBody.message.trim()) {
message = errorBody.message;
}
}
} catch {
// Keep the generic fallback when the error body is absent or invalid.
}
throw new WorkspaceProjectMoveError(message, code);
}
// A share/unshare changes membership across several projections at once
// (`recent`, `drafts`, `team`, and `all`). Do not let the coalescing window
// replay the pre-move snapshot into the immediate refresh.
invalidateWorkspaceProjectLists(context);
const json = (await resp.json()) as { project: WorkspaceProjectSummary };
return json.project;
}
function omitWorkspaceContext<T extends { workspaceContext?: WorkspaceCollabContext | null }>(
input: T,
): Omit<T, 'workspaceContext'> {
const { workspaceContext: _workspaceContext, ...rest } = input;
return rest;
}
export async function listProjects(options?: {
throwOnError?: boolean;
workspaceContext?: WorkspaceCollabContext | null;
workspaceView?: WorkspaceProjectListView;
}): Promise<Project[]> {
const context = options?.workspaceContext ?? null;
if (context) {
const summaries = await listWorkspaceProjectSummaries({
context,
workspaceView: options?.workspaceView,
throwOnError: options?.throwOnError,
});
const seenProjectIds = new Set<string>();
return summaries.flatMap((summary) => {
// Workspace list responses carry the authoritative scope on the summary
// wrapper. Remote catalog projects can omit it from the nested project,
// and a stale nested value must not leak another Workspace into a card.
const project: Project = {
...summary.project,
workspaceId: summary.workspaceId,
workspaceVisibility: summary.visibility,
};
// Workspace summaries have resource-level identities, so the same
// logical project can legitimately appear more than once when local and
// remote catalog records overlap. Project cards are opened by project.id;
// keep the first (the daemon orders local summaries before remote ones)
// instead of rendering duplicate cards with duplicate React keys.
if (seenProjectIds.has(project.id)) return [];
seenProjectIds.add(project.id);
return [project];
});
}
// No resolved workspace identity at all — either the context has not landed
// yet or this daemon has no workspace plane. The key is a constant on
// purpose: it caches the ONE unscoped `/api/projects` answer, and it is
// unreachable once a context exists (the workspace branch above returns
// first), so it can never serve one workspace's rows to another. The
// workspace-scoped read's own key is built in
// `listWorkspaceProjectSummaries` below and carries the full wire identity
// plus the requested view.
try {
return await coalescedGet('local-projects', async () => {
const resp = await fetch('/api/projects');
// Throw inside the coalesced run so a failed read is not cached — the next
// caller/poll retries immediately (see coalesced-get.ts).
if (!resp.ok) throw new Error(`projects ${resp.status}`);
const json = (await resp.json()) as { projects: Project[] };
return json.projects ?? [];
});
} catch (err) {
if (options?.throwOnError) throw err;
return [];
}
}
export async function listWorkspaceProjectSummaries(options: {
context: WorkspaceCollabContext;
throwOnError?: boolean;
workspaceView?: WorkspaceProjectListView;
}): Promise<WorkspaceProjectSummary[]> {
const { context } = options;
const workspaceView = options.workspaceView ?? 'drafts';
const key = workspaceProjectListCacheKey(context, workspaceView);
try {
return await coalescedGet(key, async () => {
const resp = await fetch(
`/api/workspaces/${encodeURIComponent(context.workspaceId)}/projects?view=${encodeURIComponent(workspaceView)}`,
{ headers: workspaceProjectHeaders(context) },
);
if (!resp.ok) throw new Error(`projects ${resp.status}`);
const json = (await resp.json()) as WorkspaceProjectsResponse;
return json.projects ?? [];
});
} catch (err) {
if (options.throwOnError) throw err;
return [];
}
}
export async function getProject(
id: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<Project | null> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(id)}`,
workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: undefined,
);
if (!resp.ok) return null;
const json = (await resp.json()) as { project: Project };
return json.project;
} catch {
return null;
}
}
export type ProjectRouteBootstrapResult =
| {
kind: 'found';
project: Project;
scope: ProjectWorkspaceScopeResponse['scope'];
resolvedDir: string | null;
}
| { kind: 'not-found' }
| { kind: 'forbidden' }
| { kind: 'unavailable' };
export type FirstOpenTeamProjectBootstrapResult =
| {
kind: 'found';
project: Project;
scope: ProjectWorkspaceScopeResponse['scope'];
resolvedDir: string | null;
awaitingFirstMaterialization: boolean;
}
| { kind: 'not-found' }
| { kind: 'forbidden' }
| { kind: 'unavailable' };
/**
* Bootstrap a fresh project deep link without borrowing the shell's ambient
* Workspace. A card-opening witness goes straight through the scoped endpoint;
* a context-free deep link uses its first headerless response only to discover
* the persisted binding, then re-confirms it with exact Workspace/member
* headers. The project row is read only after either exact check succeeds.
*/
export async function bootstrapProjectRoute(
projectId: string,
options: {
accountGeneration: number;
exactContext?: WorkspaceCollabContext | null;
},
): Promise<ProjectRouteBootstrapResult> {
const suppliedContext = options.exactContext ?? null;
const suppliedIdentity = workspaceIdentityCacheKey(suppliedContext);
const key = [
'project-route-bootstrap',
options.accountGeneration,
projectId,
suppliedIdentity,
].join(':');
const result = await coalescedGet(key, async (): Promise<ProjectRouteBootstrapResult> => {
try {
const scopeResponse = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/workspace-scope`,
{
cache: 'no-store',
...(suppliedContext
? { headers: workspaceProjectHeaders(suppliedContext) }
: {}),
},
);
if (!scopeResponse.ok) {
if (scopeResponse.status === 404) return { kind: 'not-found' };
if (scopeResponse.status === 403) return { kind: 'forbidden' };
return { kind: 'unavailable' };
}
let body = (await scopeResponse.json()) as ProjectWorkspaceScopeResponse;
if (!body.scope || body.scope.projectId !== projectId) {
return { kind: 'unavailable' };
}
let context = body.scope.context;
if (suppliedContext) {
if (
!context
|| body.scope.workspaceId !== suppliedContext.workspaceId
// Re-confirmation asks WHO, so it compares principals, not cache keys.
// The witness comes from the shell and carries the member's real role;
// the daemon's scope route answers with its placeholder `member` on
// its single branch. Demanding those agree meant a workspace owner's
// own project was never re-confirmed — `forbidden` here becomes
// `failure: 'missing'` in App with no fallback, so opening a team
// project from its directory card reported "项目不存在".
|| !isSameWorkspacePrincipal(context, suppliedContext)
) {
// A caller-supplied witness is exact authority, never a hint. If the
// daemon cannot re-confirm it, do not retry this project headerless or
// let it fall through to Personal/local scope.
return { kind: 'forbidden' };
}
} else if (context) {
// Headerless scope is discovery only. Once it names the exact persisted
// Workspace/member pair, prove that claim through the ordinary
// fail-closed authorization lane before trusting it as ProjectView's
// seed. This also ensures an already-known Team route never completes
// with headerless scope as its last authorization request.
const exactScopeResponse = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/workspace-scope`,
{
cache: 'no-store',
headers: workspaceProjectHeaders(context),
},
);
if (!exactScopeResponse.ok) {
if (exactScopeResponse.status === 404) return { kind: 'not-found' };
if (exactScopeResponse.status === 403) return { kind: 'forbidden' };
return { kind: 'unavailable' };
}
const exactBody = (await exactScopeResponse.json()) as ProjectWorkspaceScopeResponse;
const exactContext = exactBody.scope?.context;
if (
!exactBody.scope
|| exactBody.scope.projectId !== projectId
|| !exactContext
|| exactBody.scope.workspaceId !== context.workspaceId
|| workspaceIdentityCacheKey(exactContext) !== workspaceIdentityCacheKey(context)
) {
return { kind: 'forbidden' };
}
body = exactBody;
context = exactContext;
}
const projectResponse = await fetch(
`/api/projects/${encodeURIComponent(projectId)}`,
{
cache: 'no-store',
...(context ? { headers: workspaceProjectHeaders(context) } : {}),
},
);
if (!projectResponse.ok) {
if (projectResponse.status === 404) return { kind: 'not-found' };
if (projectResponse.status === 403) return { kind: 'forbidden' };
return { kind: 'unavailable' };
}
const projectBody = (await projectResponse.json()) as {
project?: Project;
resolvedDir?: unknown;
};
if (!projectBody.project || projectBody.project.id !== projectId) {
return { kind: 'unavailable' };
}
if (
context
&& projectBody.project.workspaceId !== context.workspaceId
) {
// The binding changed between the scope witness and the detail read.
// Do not mount the row under stale authority.
return { kind: 'forbidden' };
}
if (!context && projectBody.project.workspaceId) {
return { kind: 'forbidden' };
}
return {
kind: 'found',
project: projectBody.project,
scope: body.scope,
resolvedDir:
typeof projectBody.resolvedDir === 'string'
? projectBody.resolvedDir
: typeof projectBody.project.metadata?.baseDir === 'string'
? projectBody.project.metadata.baseDir
: null,
};
} catch {
return { kind: 'unavailable' };
}
});
if (result.kind === 'unavailable') evictCoalescedGet(key);
return result;
}
/**
* Progressive first-open lane for a Team project that is present in the hub
* but absent from this daemon's local data root.
*
* `/collab/bootstrap` owns the exact-directory authorization, shared-owner
* discovery, placeholder stamp, and background single-flight pull. Its
* idempotent PUT is safely replayable by the sidecar proxy after a reused
* keep-alive socket resets. A current daemon atomically binds the placeholder
* to this exact Team principal before answering; the follow-up route bootstrap
* proves that local binding through the ordinary scope/detail gates before
* ProjectView may mount.
* Older daemons leave the row unbound, which deliberately returns `unavailable`
* so App keeps the pre-existing full-materialization fallback.
*/
export async function bootstrapFirstOpenTeamProjectRoute(
projectId: string,
options: {
accountGeneration: number;
exactContext: WorkspaceCollabContext;
},
): Promise<FirstOpenTeamProjectBootstrapResult> {
const { exactContext } = options;
if (
exactContext.workspaceType !== 'team'
|| exactContext.memberStatus !== 'active'
|| exactContext.lifecycleState !== 'active'
) {
return { kind: 'not-found' };
}
let bootstrapResponse: CollabProjectBootstrapResponse;
try {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/collab/bootstrap`,
{
method: 'PUT',
cache: 'no-store',
headers: workspaceProjectHeaders(exactContext),
},
);
if (!response.ok) {
if (response.status === 403) return { kind: 'forbidden' };
if (response.status === 404) return { kind: 'not-found' };
return { kind: 'unavailable' };
}
bootstrapResponse = (await response.json()) as CollabProjectBootstrapResponse;
} catch {
return { kind: 'unavailable' };
}
// The optimistic route read immediately before this helper may have cached
// the expected 404 for the same exact principal. The successful PUT changed
// local authority state, so that negative read is no longer reusable.
evictCoalescedGet([
'project-route-bootstrap',
options.accountGeneration,
projectId,
workspaceIdentityCacheKey(exactContext),
].join(':'));
const bootstrap = await bootstrapProjectRoute(projectId, {
accountGeneration: options.accountGeneration,
exactContext,
});
if (bootstrap.kind === 'forbidden') return { kind: 'unavailable' };
if (bootstrap.kind !== 'found') return bootstrap;
if (
bootstrap.scope.kind !== 'team'
|| bootstrap.scope.context?.workspaceType !== 'team'
// Same question, same answer as the re-confirmation above: does this local
// binding belong to the exact principal that authorized the bootstrap? The
// daemon's scope placeholder role is not evidence about that, and letting it
// decide killed this progressive first-open lane outright for every
// owner/admin — every first open silently fell back to the slow
// full-materialization path.
|| !isSameWorkspacePrincipal(bootstrap.scope.context, exactContext)
) {
// A shared placeholder must never be rendered through an unbound/local or
// mismatched principal, including when the web is paired with an older
// daemon that only registered a row without its Team binding.
return { kind: 'unavailable' };
}
return {
...bootstrap,
awaitingFirstMaterialization:
bootstrapResponse.awaitingFirstMaterialization === true,
};
}
export async function getProjectDetail(
id: string,
opts?: { ensureDir?: boolean },
workspaceContext?: WorkspaceCollabContext | null,
): Promise<{ project: Project; resolvedDir: string | null } | null> {
try {
// `ensureDir` asks the daemon to materialize a managed project's folder
// before resolving it, so referencing a brand-new (empty) project yields a
// real on-disk directory instead of a path that fails existence checks.
const query = opts?.ensureDir ? '?ensureDir=1' : '';
const resp = await fetch(
`/api/projects/${encodeURIComponent(id)}${query}`,
workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: undefined,
);
if (!resp.ok) return null;
const json = (await resp.json()) as { project: Project; resolvedDir?: unknown };
return {
project: json.project,
resolvedDir: typeof json.resolvedDir === 'string' ? json.resolvedDir : null,
};
} catch {
return null;
}
}
/**
* Bounded-retry knobs for {@link createProject}. Production callers omit this
* and get the default 1s→…-jittered backoff; tests inject a no-op `sleep` (and
* usually a small `maxRetries`) so the schedule is instant and deterministic.
*/
export interface CreateProjectRetryOptions {
/** Additional attempts after the first. Default 3 (so up to 4 requests). */
maxRetries?: number;
/** Backoff shape between retries. Defaults to 500ms→4s ×2 jittered. */
backoff?: BackoffOptions;
/** Test seam for the inter-retry wait. Defaults to a real `setTimeout` sleep. */
sleep?: (ms: number) => Promise<void>;
}
const DEFAULT_CREATE_PROJECT_RETRY_BACKOFF: BackoffOptions = {
initialMs: 500,
maxMs: 4_000,
factor: 2,
jitter: true,
};
function defaultRetrySleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Parse a create/write error body into a UI message + the retryable flag. */
async function readWorkspaceWriteError(
resp: Response,
fallbackMessage: string,
): Promise<{
message: string;
retryable: boolean;
code: ApiErrorCode | null;
requestId: string | null;
}> {
let message = fallbackMessage;
let retryable = false;
let code: ApiErrorCode | null = null;
let requestId: string | null = null;
try {
const body = (await resp.json()) as { error?: unknown };
if (body.error && typeof body.error === 'object') {
const error = body.error as {
code?: unknown;
message?: unknown;
requestId?: unknown;
retryable?: unknown;
};
if (typeof error.message === 'string' && error.message.trim()) {
message = error.message;
}
if (error.retryable === true) retryable = true;
if (
typeof error.code === 'string'
&& (API_ERROR_CODES as readonly string[]).includes(error.code)
) code = error.code as ApiErrorCode;
if (typeof error.requestId === 'string' && error.requestId.trim()) {
requestId = error.requestId;
}
}
} catch {
// Keep the generic fallback when the error body is absent or invalid.
}
return { message, retryable, code, requestId };
}
export class ProjectCreateError extends Error {
constructor(
message: string,
readonly status: number | null,
readonly code: ApiErrorCode | null,
readonly retryable: boolean,
readonly requestId: string | null,
) {
super(message);
this.name = 'ProjectCreateError';
}
}
/**
* Whether a failed workspace-scoped write should be retried. The daemon marks
* `WORKSPACE_AUTHORITY_UNAVAILABLE` (vela membership authority momentarily down)
* as a 503 `retryable: true`; a cross-workspace 403 or a validation 4xx is
* permanent and must surface immediately.
*/
function isRetryableWorkspaceWriteFailure(status: number, retryable: boolean): boolean {
return status === 503 && retryable;
}
export async function createProject(
input: {
/** Optional caller-minted id used for an optimistic route handoff. */
id?: string;
name: string;
projectLocationId?: string;
skillId: string | null;
skillCatalogScope?: LocalCatalogScope | null;
designSystemId: string | null;
designSystemCatalogScope?: LocalCatalogScope | null;
pendingPrompt?: string;
metadata?: ProjectMetadata;
conversationMode?: ChatSessionMode;
/** Optional caller-minted id for the seeded conversation (same handoff). */
conversationId?: string;
// Plan §3.A1 / spec §11.5 — POST /api/projects accepts a pluginId
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
// project. Used by the PluginLoopHome flow on Home.
pluginId?: string;
pluginSource?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
automaticStrategyTaskProfile?: ProjectScenarioTaskProfile;
/**
* Identity of the official example card the user picked under an automatic
* OD Next route. A claim, not content: the daemon re-resolves it through
* the local catalogue. Never accompanies `pluginId`/`appliedPluginSnapshotId`.
*/
exampleReference?: CreateProjectExampleReference;
workspaceContext?: WorkspaceCollabContext | null;
},
retryOptions: CreateProjectRetryOptions = {},
): Promise<{ project: Project; conversationId: string; appliedPluginSnapshotId?: string }> {
const maxRetries = retryOptions.maxRetries ?? 3;
const sleep = retryOptions.sleep ?? defaultRetrySleep;
const backoff = new BackoffController(
retryOptions.backoff ?? DEFAULT_CREATE_PROJECT_RETRY_BACKOFF,
);
try {
// `randomUUID` falls back to `crypto.getRandomValues` / `Math.random`
// when `crypto.randomUUID` is unavailable. OpenDesign served over
// plain HTTP on a LAN IP (Docker / unRAID self-hosting) is a
// non-secure context, where `crypto.randomUUID` is undefined and
// calling it directly throws — the surrounding try/catch then turns
// the Create button into a silent no-op (issue #849).
//
// The id is minted ONCE and reused across retries: a retryable 503 fails
// vela's authority check before any row is inserted, so replaying the same
// client-provided id is idempotent, never a duplicate project.
const id = input.id ?? randomUUID();
for (let attempt = 0; ; attempt += 1) {
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(input.workspaceContext ? workspaceProjectHeaders(input.workspaceContext) : {}),
},
body: JSON.stringify({ id, ...omitWorkspaceContext(input) }),
});
if (resp.ok) {
const created = (await resp.json()) as {
project: Project;
conversationId: string;
appliedPluginSnapshotId?: string;
};
// Preserve the exact Workspace authority used by this create request.
// `useProjectCollab` may use this only to skip the initial status-unknown
// read-only window; another Workspace with the same project id must not
// inherit the signal.
markProjectCreatedByViewer(created.project.id, input.workspaceContext ?? null);
return created;
}
if (await isDaemonProxyConnectionFailure(resp)) {
throw new ProjectCreateError(
'Could not reach the local OpenDesign service',
null,
null,
true,
null,
);
}
const { message, retryable, code, requestId } = await readWorkspaceWriteError(
resp,
'Could not create project',
);
if (isRetryableWorkspaceWriteFailure(resp.status, retryable) && attempt < maxRetries) {
await sleep(backoff.nextDelay());
continue;
}
throw new ProjectCreateError(message, resp.status, code, retryable, requestId);
}
} catch (err) {
throw err instanceof Error ? err : new Error('Could not create project');
}
}
export async function createDesignSystemProjectFromProject(
projectId: string,
input: { name?: string; pendingPrompt?: string } = {},
workspaceContext?: WorkspaceCollabContext | null,
): Promise<CreateDesignSystemProjectFromProjectResponse> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/design-system-copy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(input),
});
if (!resp.ok) {
let message = 'Could not create design system';
try {
const body = await resp.json() as { error?: unknown };
if (
body.error &&
typeof body.error === 'object' &&
'message' in body.error &&
typeof body.error.message === 'string' &&
body.error.message.trim()
) {
message = body.error.message;
} else if (typeof body.error === 'string' && body.error.trim()) {
message = body.error;
}
} catch {
// Keep the generic fallback when the error body is absent or invalid.
}
throw new Error(message);
}
return (await resp.json()) as CreateDesignSystemProjectFromProjectResponse;
} catch (err) {
throw err instanceof Error ? err : new Error('Could not create design system');
}
}
export async function duplicateProject(
projectId: string,
input: { name?: string } = {},
workspaceContext?: WorkspaceCollabContext | null,
): Promise<DuplicateProjectResponse> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(input),
});
if (!resp.ok) {
let message = 'Could not duplicate project';
try {
const body = await resp.json() as { error?: unknown };
if (
body.error &&
typeof body.error === 'object' &&
'message' in body.error &&
typeof body.error.message === 'string' &&
body.error.message.trim()
) {
message = body.error.message;
} else if (typeof body.error === 'string' && body.error.trim()) {
message = body.error;
}
} catch {
// Keep the generic fallback when the error body is absent or invalid.
}
throw new Error(message);
}
const created = (await resp.json()) as DuplicateProjectResponse;
markProjectCreatedByViewer(created.project.id, workspaceContext ?? null);
return created;
} catch (err) {
throw err instanceof Error ? err : new Error('Could not duplicate project');
}
}
export async function pickLocalFolderPath(): Promise<string | null> {
const resp = await fetch('/api/dialog/open-folder', {
method: 'POST',
});
if (!resp.ok) {
let message = 'Could not open folder picker';
try {
const body = await resp.json() as { error?: unknown };
if (typeof body.error === 'string' && body.error.trim()) {
message = body.error;
} else if (
body.error
&& typeof body.error === 'object'
&& 'message' in body.error
&& typeof body.error.message === 'string'
&& body.error.message.trim()
) {
message = body.error.message;
}
} catch { /* use default message */ }
throw new Error(message);
}
const body = await resp.json() as { path?: unknown };
if (body.path == null) return null;
if (typeof body.path !== 'string') {
throw new Error('Could not open folder picker');
}
return body.path.length > 0 ? body.path : null;
}
export async function importFolderProject(
input: ImportFolderRequest,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<ImportFolderResponse> {
const resp = await fetch('/api/import/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(input),
});
if (!resp.ok) {
let message = 'Failed to import folder';
try {
const body = await resp.json();
if (body?.error?.message) message = body.error.message;
} catch { /* use default message */ }
throw new Error(message);
}
return (await resp.json()) as ImportFolderResponse;
}
export async function importClaudeDesignZip(
file: File,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<{ project: Project; conversationId: string; entryFile: string }> {
const form = new FormData();
form.append('file', file);
const resp = await fetch('/api/import/claude-design', {
method: 'POST',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
body: form,
});
if (!resp.ok) {
const payload = await resp.json().catch(() => null);
const message =
payload != null &&
typeof payload === 'object' &&
typeof (payload as { error?: unknown }).error === 'string'
? (payload as { error: string }).error
: `Import failed (${resp.status})`;
throw new Error(message);
}
return (await resp.json()) as {
project: Project;
conversationId: string;
entryFile: string;
};
}
// ---------- templates ----------
/**
* Bumped by every successful local template mutation, and part of the read key.
*
* `ttl = 0` stops a settled result from being reused; it does not stop a new
* caller from JOINING a request that is still in flight — and the caller that
* follows a mutation is exactly the one that must not join. `handleDeleteTemplate`
* awaits `deleteTemplate` and then refreshes, and the daemon answers
* `/api/templates` from a snapshot taken when the request arrived, so joining a
* pre-delete GET would leave the deleted template on screen until something
* else happened to refetch.
*/
let templateListMutationGeneration = 0;
function noteTemplateListMutation(): void {
templateListMutationGeneration += 1;
}
export async function listTemplates(): Promise<ProjectTemplate[]> {
// Same launch-burst shape as the design-system catalog: App's one-shot
// bootstrap and the home-route effect both want this list on the same pass,
// and both must keep their own read — one settles the entry view, the other
// exists to pick up a template saved inside a project. Neither can drop its
// read; on the wire they are one request, and on a cold Home load they land
// together and take two of the browser's ~6 connection slots.
//
// SINGLE-FLIGHT ONLY (ttl 0). The home-route effect and the save handler's
// own refresh both exist to observe a change that just happened, so a shared
// settled answer would hand them the list they were fired to replace.
//
// One global key, deliberately not partitioned by Workspace identity: the
// daemon handler ignores the request entirely (`(_req, res) =>`) and answers
// from its local store, so this response cannot vary by caller identity.
// Throwing inside keeps a transient failure out of the shared entry, so the
// next caller retries instead of joining a dead read.
try {
return await coalescedGet(`project-templates:${templateListMutationGeneration}`, async () => {
const resp = await fetch('/api/templates');
if (!resp.ok) throw new Error(`templates ${resp.status}`);
const json = (await resp.json()) as { templates: ProjectTemplate[] };
return json.templates ?? [];
}, 0);
} catch {
return [];
}
}
export async function getTemplate(id: string): Promise<ProjectTemplate | null> {
try {
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
const json = (await resp.json()) as { template: ProjectTemplate };
return json.template;
} catch {
return null;
}
}
export async function saveTemplate(input: {
name: string;
description?: string;
sourceProjectId: string;
}): Promise<ProjectTemplate | null> {
try {
const resp = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) return null;
noteTemplateListMutation();
const json = (await resp.json()) as { template: ProjectTemplate };
return json.template;
} catch {
return null;
}
}
export async function deleteTemplate(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (resp.ok) noteTemplateListMutation();
return resp.ok;
} catch {
return false;
}
}
type ProjectPatch = Omit<Partial<Project>, 'pendingPrompt' | 'customInstructions'> & {
pendingPrompt?: Project['pendingPrompt'] | null;
customInstructions?: string | null;
};
export async function patchProject(
id: string,
patch: ProjectPatch,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(patch),
});
if (!resp.ok) return null;
const json = (await resp.json()) as { project: Project };
// Any successful project patch can change fields rendered by the project
// lists (name, metadata, updatedAt, bindings displayed on cards). A list
// read started immediately after this write must not reuse the settled
// pre-write value from coalescedGet's one-second burst window.
if (workspaceContext) {
invalidateWorkspaceProjectLists(
workspaceContext,
currentWorkspaceAccountGeneration(),
);
} else {
evictCoalescedGet('local-projects');
}
return json.project;
} catch {
return null;
}
}
/**
* Delete a project.
*
* `workspaceContext`, when known, MUST be attached: `enforceWorkspaceProjectMutation`
* (apps/daemon/src/routes/project/index.ts) treats a request carrying NEITHER
* `x-od-workspace-id` NOR `x-od-workspace-member-id` as a legacy pre-workspace
* caller and skips its ownership check entirely (`ctx === null` → allowed).
* Omitting these headers — which this call used to do unconditionally — meant
* every delete from a workspace-team build bypassed the daemon's own
* cross-workspace permission check, so a project that leaked into the wrong
* workspace's 草稿 grid (recvq5bz3HIDVt) was ALSO really deletable from there
* (recvq5ecTkar91), not just visible. See {@link moveWorkspaceProject} for the
* same headers on the sibling move endpoint.
*/
export async function deleteProject(
id: string,
workspaceContext?: WorkspaceCollabContext | null,
onShareResiduals?: (rows: ReadonlyArray<ProjectDeleteShareResidual>) => void,
): Promise<true> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
method: 'DELETE',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
});
if (!resp.ok) {
let message = `project delete failed with status ${resp.status}`;
let code: string | undefined;
try {
const payload = await resp.json() as {
error?: string | { code?: unknown; message?: unknown };
code?: unknown;
message?: unknown;
};
const envelope = payload.error && typeof payload.error === 'object'
? payload.error
: null;
const rawCode = envelope?.code ?? payload.code;
const rawMessage = envelope?.message
?? payload.message
?? (typeof payload.error === 'string' ? payload.error : undefined);
code = boundedRequestErrorCode(rawCode);
if (typeof rawMessage === 'string' && rawMessage.trim()) {
message = rawMessage;
}
} catch {
// Keep the stable HTTP fallback when a legacy daemon returns no JSON.
}
// DELETE is idempotent for the web client. A second tab, a stale project
// list, or a retry whose first response was lost can legitimately reach
// the daemon after the project row is already gone. Only accept the
// daemon's structured PROJECT_NOT_FOUND response here — a generic 404
// can still mean the route itself is unavailable on an incompatible
// daemon and must remain visible as a failure.
if (resp.status === 404 && code === 'PROJECT_NOT_FOUND') {
removeCachedTabs(id, workspaceContext);
removeDesignBrowserProjectCache(id);
return true;
}
throw new ProjectDeleteError(message, resp.status, code);
}
// This response is the only delivery of orphaned share information. Hand it
// to the app-level queue before cache cleanup/navigation can remove its origin.
const payload: unknown = await resp.json().catch(() => null);
if (payload && typeof payload === 'object' && 'shareResiduals' in payload && Array.isArray(payload.shareResiduals)) {
const rows = payload.shareResiduals.filter((row): row is ProjectDeleteShareResidual =>
row && typeof row === 'object' && typeof row.filePath === 'string'
&& typeof row.slug === 'string' && typeof row.retrying === 'boolean');
if (rows.length) onShareResiduals?.(rows.map(row => Object.freeze({ ...row })));
}
// Drop per-project browser caches once the project is gone server-side so
// they do not accumulate in localStorage for the lifetime of the profile.
removeCachedTabs(id, workspaceContext);
removeDesignBrowserProjectCache(id);
return true;
} catch (error) {
if (error instanceof ProjectDeleteError) throw error;
throw new ProjectDeleteError(
error instanceof Error ? error.message : 'Project delete request failed.',
undefined,
'network_error',
);
}
}
// ---------- conversations ----------
export class ProjectConversationsHttpError extends Error {
constructor(
readonly status: number,
message = `conversations ${status}`,
) {
super(message);
this.name = 'ProjectConversationsHttpError';
}
}
type CreateConversationOptions = {
seedFromConversationId?: string | null;
forkAfterMessageId?: string | null;
sessionMode?: ChatSessionMode;
// The one in-memory fork point to retry with when it never reached the DB.
forkFallbackMessage?: ChatMessage;
forkFallbackPredecessorMessageId?: string | null;
workspaceContext?: WorkspaceCollabContext | null;
throwOnError?: boolean;
};
export async function listConversations(
projectId: string,
options?: {
throwOnError?: boolean;
workspaceContext?: WorkspaceCollabContext | null;
},
): Promise<Conversation[]> {
const workspaceContext = options?.workspaceContext ?? null;
const readKey = `project-conversations:${projectId}:${workspaceIdentityCacheKey(workspaceContext)}`;
try {
// Concurrent consumers of one project's conversation list share a single
// request per burst (Batch A §4.3); conversation writes below evict.
const json = await coalescedGet(
readKey,
async () => {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: undefined,
);
if (!resp.ok) throw new ProjectConversationsHttpError(resp.status);
return (await resp.json()) as { conversations: Conversation[] };
},
);
return json.conversations ?? [];
} catch (err) {
if (options?.throwOnError) throw err;
return [];
}
}
/** Thin invalidation for the shared conversations read after a write. */
function evictConversationsRead(
projectId: string,
workspaceContext?: WorkspaceCollabContext | null,
): void {
evictCoalescedGet(
`project-conversations:${projectId}:${workspaceIdentityCacheKey(workspaceContext)}`,
);
}
export async function createConversation(
projectId: string,
title?: string,
// Side Chat: seed the new conversation with another conversation's context
// by copying its messages. `forkAfterMessageId` narrows that copy to a
// specific point in the source history.
opts?: CreateConversationOptions,
): Promise<Conversation | null> {
try {
const body: CreateConversationRequest = { title };
if (opts?.sessionMode) {
body.sessionMode = opts.sessionMode;
}
if (opts?.seedFromConversationId) {
body.seedFromConversationId = opts.seedFromConversationId;
}
if (opts?.forkAfterMessageId) {
body.forkAfterMessageId = opts.forkAfterMessageId;
}
let resp = await postConversation(projectId, body, opts?.workspaceContext);
if (!resp.ok) {
const message = await readErrorMessage(resp);
const fallbackMessage = compactForkFallbackMessage(opts);
if (resp.status === 404 && message === 'fork message not found' && fallbackMessage) {
resp = await postConversation(
projectId,
{
...body,
forkFallbackMessage: fallbackMessage,
forkFallbackPredecessorMessageId: opts?.forkFallbackPredecessorMessageId,
},
opts?.workspaceContext,
);
} else {
throw new ProjectConversationsHttpError(resp.status, message);
}
}
if (!resp.ok) {
throw new ProjectConversationsHttpError(resp.status, await readErrorMessage(resp));
}
const json = (await resp.json()) as { conversation: Conversation };
evictConversationsRead(projectId, opts?.workspaceContext);
return json.conversation;
} catch (error) {
if (opts?.throwOnError) throw error;
return null;
}
}
function postConversation(
projectId: string,
body: CreateConversationRequest,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<Response> {
return fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(body),
},
);
}
function compactForkFallbackMessage(
opts: CreateConversationOptions | undefined,
): ChatMessage | null {
const forkMessage = opts?.forkFallbackMessage;
if (!forkMessage || opts?.forkFallbackPredecessorMessageId === undefined) return null;
return {
id: forkMessage.id,
role: forkMessage.role,
content: forkMessage.content,
};
}
export async function patchConversation(
projectId: string,
conversationId: string,
patch: Partial<Conversation>,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<Conversation | null> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(patch),
},
);
if (!resp.ok) return null;
const json = (await resp.json()) as { conversation: Conversation };
evictConversationsRead(projectId, workspaceContext);
return json.conversation;
} catch {
return null;
}
}
export async function deleteConversation(
projectId: string,
conversationId: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
{
method: 'DELETE',
...(workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: {}),
},
);
if (resp.ok) evictConversationsRead(projectId, workspaceContext);
return resp.ok;
} catch {
return false;
}
}
// ---------- messages ----------
/**
* A failed authoritative transcript read. Callers must not reinterpret this
* as an empty conversation: Home auto-send and recovery flows make decisions
* from that distinction.
*/
export class ProjectMessageListError extends Error {
constructor(
message: string,
readonly status: number | null,
readonly code: string | null,
readonly retryable: boolean,
) {
super(message);
this.name = 'ProjectMessageListError';
}
}
async function readProjectMessageListError(resp: Response): Promise<{
message: string;
code: string | null;
retryable: boolean;
}> {
let message = `Could not load messages for this conversation (${resp.status}).`;
let code: string | null = null;
// Legacy errors and proxy failures may omit a retryability hint. This is a
// read, so transient HTTP statuses can use the caller's bounded retry policy.
let retryable = resp.status === 408
|| resp.status === 429
|| (resp.status >= 500 && resp.status <= 599);
try {
const payload = await resp.json() as {
retryable?: unknown;
error?: string | {
code?: unknown;
message?: unknown;
retryable?: unknown;
};
};
if (typeof payload.retryable === 'boolean') retryable = payload.retryable;
if (payload.error && typeof payload.error === 'object') {
const rawCode = payload.error.code;
code = typeof rawCode === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/u.test(rawCode)
? rawCode
: null;
if (typeof payload.error.message === 'string' && payload.error.message.trim()) {
message = payload.error.message;
}
if (typeof payload.error.retryable === 'boolean') {
retryable = payload.error.retryable;
}
} else if (typeof payload.error === 'string' && payload.error.trim()) {
message = payload.error;
}
} catch {
// Keep the stable HTTP fallback for legacy/non-JSON responses.
}
return { message, code, retryable };
}
export async function listMessages(
projectId: string,
conversationId: string,
workspaceContext?: WorkspaceCollabContext | null,
signal?: AbortSignal,
): Promise<ChatMessage[]> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
workspaceContext || signal
? {
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
...(signal ? { signal } : {}),
}
: undefined,
);
if (!resp.ok) {
const failure = await readProjectMessageListError(resp);
throw new ProjectMessageListError(
failure.message,
resp.status,
failure.code,
failure.retryable,
);
}
const json = (await resp.json()) as { messages: ChatMessage[] };
return json.messages ?? [];
} catch (error) {
if (error instanceof ProjectMessageListError) throw error;
throw new ProjectMessageListError(
error instanceof Error ? error.message : 'Could not load messages for this conversation.',
null,
null,
true,
);
}
}
export interface SaveMessageOptions {
telemetryFinalized?: boolean;
/** Claim the row once: the daemon keeps an existing row and returns it. */
createOnly?: boolean;
workspaceContext?: WorkspaceCollabContext | null;
// Set during page-unload paths (pagehide / visibilitychange→hidden) so
// the in-flight PUT survives even if the document tears down before the
// response arrives. Without keepalive the browser cancels the fetch
// and the daemon never sees the final buffered text chunk.
keepalive?: boolean;
}
export async function saveMessage(
projectId: string,
conversationId: string,
message: ChatMessage,
options: SaveMessageOptions = {},
): Promise<ChatMessage | null> {
try {
const body = {
...message,
...(options.telemetryFinalized ? { telemetryFinalized: true } : {}),
...(options.createOnly ? { createOnly: true } : {}),
};
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(message.id)}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(options.workspaceContext
? workspaceProjectHeaders(options.workspaceContext)
: {}),
},
body: JSON.stringify(body),
...(options.keepalive ? { keepalive: true } : {}),
},
);
if (!response.ok) return null;
// The stored row, which a create-only claim may have kept from an earlier
// writer. Callers that care compare it against what they sent.
const saved = (await response.json()) as { message?: ChatMessage };
return saved.message ?? null;
} catch {
// best-effort persistence — UI keeps the message in-memory either way
return null;
}
}
// ---------- terminals ----------
//
// Interactive PTY sessions rooted at the project working directory. The daemon
// streams output down over SSE (`GET .../stream`) and accepts keystrokes /
// resizes back up over plain POST — see `packages/contracts/src/api/terminals.ts`.
// `<TerminalViewer>` drives `terminalStreamUrl` directly via EventSource; these
// helpers cover the request/response endpoints.
export async function createTerminal(
projectId: string,
init?: CreateTerminalRequest,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<TerminalSession | null> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terminals`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(init ?? {}),
},
);
if (!resp.ok) return null;
const json = (await resp.json()) as { terminal: TerminalSession };
return json.terminal ?? null;
} catch {
return null;
}
}
/** SSE endpoint a `<TerminalViewer>` subscribes to for raw PTY output. */
export function terminalStreamUrl(
projectId: string,
terminalId: string,
workspaceContext?: WorkspaceCollabContext | null,
): string {
return workspaceResourceUrl(
`/api/projects/${encodeURIComponent(projectId)}/terminals/${encodeURIComponent(terminalId)}/stream`,
workspaceContext,
);
}
export async function sendTerminalStdin(
projectId: string,
terminalId: string,
data: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terminals/${encodeURIComponent(terminalId)}/stdin`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ data }),
},
);
return resp.ok;
} catch {
return false;
}
}
export async function resizeTerminal(
projectId: string,
terminalId: string,
cols: number,
rows: number,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terminals/${encodeURIComponent(terminalId)}/resize`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ cols, rows }),
},
);
return resp.ok;
} catch {
return false;
}
}
export async function killTerminal(
projectId: string,
terminalId: string,
// Page-unload paths set keepalive so the kill survives document teardown,
// mirroring `saveMessage`. Without it the browser cancels the fetch and the
// PTY leaks until the daemon GCs it.
options: {
keepalive?: boolean;
workspaceContext?: WorkspaceCollabContext | null;
} = {},
): Promise<boolean> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terminals/${encodeURIComponent(terminalId)}/kill`,
{
method: 'POST',
...(options.workspaceContext
? { headers: workspaceProjectHeaders(options.workspaceContext) }
: {}),
...(options.keepalive ? { keepalive: true } : {}),
},
);
return resp.ok;
} catch {
return false;
}
}
// ---------- tabs ----------
const PROJECT_TABS_CACHE_PREFIX = 'open-design:project-tabs:v1:';
function tabsCacheKey(
projectId: string,
workspaceContext?: WorkspaceCollabContext | null,
): string {
if (!workspaceContext) return `${PROJECT_TABS_CACHE_PREFIX}${projectId}`;
return `${PROJECT_TABS_CACHE_PREFIX}${projectId}:${workspaceIdentityCacheKey(workspaceContext)}`;
}
function normalizeTabsState(value: unknown): OpenTabsState | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (!Array.isArray(record.tabs) || !record.tabs.every((tab) => typeof tab === 'string')) {
return null;
}
const browserTabs = Array.isArray(record.browserTabs)
? record.browserTabs.filter(
(tab) =>
Boolean(tab) &&
typeof tab === 'object' &&
!Array.isArray(tab) &&
typeof (tab as Record<string, unknown>).id === 'string' &&
typeof (tab as Record<string, unknown>).label === 'string',
) as OpenTabsState['browserTabs']
: undefined;
const state: OpenTabsState = {
tabs: record.tabs.slice() as string[],
active: typeof record.active === 'string' ? record.active : null,
};
if (browserTabs && browserTabs.length > 0) state.browserTabs = browserTabs;
if (record.hasSavedState === true) state.hasSavedState = true;
if (typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt)) {
state.updatedAt = record.updatedAt;
}
return state;
}
function readCachedTabs(
projectId: string,
workspaceContext?: WorkspaceCollabContext | null,
): OpenTabsState | null {
if (typeof window === 'undefined') return null;
try {
const cached = normalizeTabsState(JSON.parse(
window.localStorage.getItem(tabsCacheKey(projectId, workspaceContext)) ?? 'null',
));
// An existing cache is a saved user choice, even for older entries without
// this marker or an intentionally empty selection. Defaults must not win
// while the debounced daemon save is still pending.
return cached ? { ...cached, hasSavedState: true } : null;
} catch {
return null;
}
}
function removeCachedTabs(
projectId: string,
workspaceContext?: WorkspaceCollabContext | null,
): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(tabsCacheKey(projectId, workspaceContext));
} catch {
// Ignore private-mode/quota errors; the cache entry is best-effort.
}
}
function writeCachedTabs(
projectId: string,
state: OpenTabsState,
workspaceContext?: WorkspaceCollabContext | null,
): OpenTabsState {
const next: OpenTabsState = {
...state,
hasSavedState: true,
updatedAt: Date.now(),
};
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(
tabsCacheKey(projectId, workspaceContext),
JSON.stringify(next),
);
} catch {
// Ignore quota/private-mode failures. The daemon save below is canonical.
}
}
return next;
}
function newestTabsState(
first: OpenTabsState | null,
second: OpenTabsState | null,
): OpenTabsState {
if (!first && !second) return { tabs: [], active: null };
if (!first) return second!;
if (!second) return first;
return (second.updatedAt ?? 0) > (first.updatedAt ?? 0) ? second : first;
}
async function persistTabsToDaemon(
projectId: string,
state: OpenTabsState,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<void> {
const requestKey =
`project-tabs:${projectId}:${workspaceIdentityCacheKey(workspaceContext)}`;
// Thin invalidation: a write makes any burst-shared read stale.
evictCoalescedGet(requestKey);
await fetch(`/api/projects/${encodeURIComponent(projectId)}/tabs`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(state),
keepalive: true,
});
}
export async function loadTabs(
projectId: string,
workspaceContext?: WorkspaceCollabContext | null,
options: {
reconcileNewerCacheToDaemon?: boolean;
} = {},
): Promise<OpenTabsState> {
const cached = readCachedTabs(projectId, workspaceContext);
const requestKey =
`project-tabs:${projectId}:${workspaceIdentityCacheKey(workspaceContext)}`;
try {
// Concurrent mounts share one daemon read per burst (Batch A §4.3); the
// per-caller cache reconciliation below still runs for every caller.
const saved = await coalescedGet(requestKey, async () => {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/tabs`,
workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: undefined,
);
if (!resp.ok) throw new Error(`tabs ${resp.status}`);
return normalizeTabsState(await resp.json());
});
const latest = newestTabsState(cached, saved);
if (
options.reconcileNewerCacheToDaemon !== false
&& cached
&& latest === cached
&& (cached.updatedAt ?? 0) > (saved?.updatedAt ?? 0)
) {
void persistTabsToDaemon(projectId, cached, workspaceContext).catch(() => {});
}
return latest;
} catch {
return cached ?? { tabs: [], active: null };
}
}
export async function saveTabs(
projectId: string,
state: OpenTabsState,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<void> {
const next = writeCachedTabs(projectId, state, workspaceContext);
try {
await persistTabsToDaemon(projectId, next, workspaceContext);
} catch {
// best-effort
}
}
/**
* Write tab state to the local cache ONLY (synchronous localStorage), returning
* the `updatedAt`-stamped state. Callers that debounce the canonical daemon
* write use this so the cache is always current — `loadTabs` reconciles cache
* vs daemon by `updatedAt`, so a debounced (or dropped) daemon PUT never loses
* data: a newer cache is re-pushed on next load.
*/
export function cacheTabsLocally(
projectId: string,
state: OpenTabsState,
workspaceContext?: WorkspaceCollabContext | null,
): OpenTabsState {
return writeCachedTabs(projectId, state, workspaceContext);
}
/** Persist already-stamped tab state to the daemon (the debounced write). */
export async function persistTabsToDaemonNow(
projectId: string,
state: OpenTabsState,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<void> {
try {
await persistTabsToDaemon(projectId, state, workspaceContext);
} catch {
// best-effort; the local cache (written via cacheTabsLocally) is canonical
// and will re-push on the next loadTabs reconciliation.
}
}
// ---------- plugins ----------
// Plan §3.C1 — plugin discovery + apply.
//
// applyPlugin() is the canonical entry point for both the inline rail
// (NewProjectPanel + ChatComposer) and the marketplace detail page. A caller
// with a catalogue record supplies its exact local source; id-only callers
// retain the Workspace-scoped compatibility route. Both return everything the
// composer needs:
// - query (pre-filled brief)
// - contextItems (chip strip)
// - inputs (form fields)
// - appliedPlugin (snapshot id; sent back on POST /api/runs to pin
// the prompt block to the frozen view)
export interface ListPluginsOptions {
includeHidden?: boolean;
/**
* When present, attaches the same workspace identity headers project reads
* already carry (`workspaceProjectHeaders`) so the daemon's `GET /api/plugins`
* can apply its workspace-scoped filter (routes/plugins/index.ts +
* `listInstalledPlugins`'s one-way "unclaimed visible everywhere, claimed
* elsewhere hidden" rule). Omit for callers that genuinely need the
* headerless compatibility catalogue. The complete Workspace/member
* identity is part of the cache key below, so a scoped read can never leak
* into that headerless partition or a differently scoped caller.
*/
workspaceContext?: WorkspaceCollabContext | null;
/**
* Account boundary paired with the complete Workspace identity below. Tests
* and callers holding a captured generation may pass it explicitly; normal
* UI callers use the current generation.
*/
accountGeneration?: number;
}
interface CachedVisiblePlugins {
plugins: InstalledPluginRecord[];
cachedAt: number;
}
// The plugin catalogue is filtered by the request's Workspace headers. Keep
// the warm snapshot that avoids Home's 1-2s remount stall, but partition it by
// BOTH the signed-in account generation and every identity field carried on
// the wire. A display cache is never an authorization witness: callers still
// pass the verified context to mutations independently.
const cachedVisiblePlugins = new Map<string, CachedVisiblePlugins>();
// Every request start and explicit invalidation advances the exact partition's
// generation. Deleting the settled value alone is insufficient: an older
// `listPlugins()` can resolve after a replacement read and otherwise put its
// stale snapshot back into this cache. Latest-started-wins also covers ordinary
// same-key request races that do not pass through invalidation. Generations use
// the same account + Workspace key as the values, so activity in A cannot
// suppress a concurrent B (or next-account) response.
const pluginCatalogCacheGenerations = new Map<string, number>();
const PLUGINS_CACHE_TTL_MS = 10_000;
const MAX_PLUGIN_CATALOG_CACHE_ENTRIES = 24;
export function pluginCatalogCacheKey(
options: Pick<ListPluginsOptions, 'workspaceContext' | 'accountGeneration'> = {},
): string {
return JSON.stringify([
options.accountGeneration ?? currentWorkspaceAccountGeneration(),
workspaceIdentityCacheKey(options.workspaceContext ?? null),
]);
}
function cacheVisiblePlugins(
key: string,
plugins: InstalledPluginRecord[],
): void {
cachedVisiblePlugins.delete(key);
cachedVisiblePlugins.set(key, { plugins, cachedAt: Date.now() });
while (cachedVisiblePlugins.size > MAX_PLUGIN_CATALOG_CACHE_ENTRIES) {
const oldest = cachedVisiblePlugins.keys().next().value as string | undefined;
if (!oldest) break;
cachedVisiblePlugins.delete(oldest);
}
}
export async function listPlugins(
options: ListPluginsOptions = {},
): Promise<InstalledPluginRecord[]> {
const cacheKey = pluginCatalogCacheKey(options);
const requestGeneration = (pluginCatalogCacheGenerations.get(cacheKey) ?? 0) + 1;
pluginCatalogCacheGenerations.set(cacheKey, requestGeneration);
// NOT single-flighted, deliberately. `FileWorkspace`'s load effect fires this
// twice ~4ms apart on a cold conversation open, and a ttl-0 join would remove
// the second request — but it would also remove the ordinary same-key request
// race that `pluginCatalogCacheGenerations` above exists to arbitrate, and
// that `tests/state/projects.test.ts` pins ("keeps the latest-started
// same-scope plugin read cached when responses finish in reverse order").
// Collapsing identical concurrent reads makes that race unreachable rather
// than merely handled, which is a change to this module's stated concurrency
// contract, not a request-count change. Left for the owner of that contract
// to decide.
try {
const resp = await fetch(
'/api/plugins',
options.workspaceContext ? { headers: workspaceProjectHeaders(options.workspaceContext) } : undefined,
);
if (!resp.ok) return [];
const json = (await resp.json()) as { plugins?: InstalledPluginRecord[] };
const plugins = (json.plugins ?? []).filter((plugin) => !isRetiredHyperFramesPlugin(plugin));
const visible = plugins.filter(isVisiblePlugin);
if (pluginCatalogCacheGenerations.get(cacheKey) === requestGeneration) {
cacheVisiblePlugins(cacheKey, visible);
}
return options.includeHidden ? plugins : visible;
} catch {
return [];
}
}
// Return the cached visible plugins without hitting the network when the cache
// is still within its TTL; otherwise fetch (which refreshes the cache). Used by
// surfaces that mount often (Home) where a slightly stale list is fine and the
// heavy `/api/plugins` round trip per mount is not.
export async function listPluginsFresh(
options: Pick<ListPluginsOptions, 'workspaceContext' | 'accountGeneration'> = {},
): Promise<InstalledPluginRecord[]> {
const cached = cachedVisiblePlugins.get(pluginCatalogCacheKey(options));
if (cached && Date.now() - cached.cachedAt < PLUGINS_CACHE_TTL_MS) {
return cached.plugins.filter((plugin) => !isRetiredHyperFramesPlugin(plugin));
}
return listPlugins(options);
}
/**
* Return the last successful unscoped plugin catalog regardless of its refresh
* age. Frequently remounted surfaces use this snapshot for their first render
* while `listPluginsFresh()` revalidates an expired catalog in the background.
* A null result is the only state that still requires the cold-start loading
* guard: once a catalog has loaded, network latency must not make known plugin
* actions temporarily unactionable again.
*/
export function readCachedVisiblePlugins(
options: Pick<ListPluginsOptions, 'workspaceContext' | 'accountGeneration'> = {},
): InstalledPluginRecord[] | null {
return cachedVisiblePlugins.get(pluginCatalogCacheKey(options))?.plugins.filter((plugin) => !isRetiredHyperFramesPlugin(plugin)) ?? null;
}
/**
* Evict one authenticated plugin-catalog partition after a thin Workspace
* invalidation. Both account generation and the complete Workspace/member
* identity are required so an event for A cannot discard B's warm catalog.
*/
export function invalidatePluginCatalogCache(options: {
workspaceContext: WorkspaceCollabContext | null;
accountGeneration: number;
}): void {
const cacheKey = pluginCatalogCacheKey(options);
cachedVisiblePlugins.delete(cacheKey);
pluginCatalogCacheGenerations.set(
cacheKey,
(pluginCatalogCacheGenerations.get(cacheKey) ?? 0) + 1,
);
}
// Test-only: drop the warm visible-plugins cache so each case starts cold. The
// module-level cache intentionally survives Home remounts in the app, but that
// same persistence leaks across test cases in a worker (a case's mocked
// `/api/plugins` payload would satisfy the next case via `listPluginsFresh`).
// The web vitest setup calls this in a global `afterEach`.
export function resetPluginsCache(): void {
cachedVisiblePlugins.clear();
pluginCatalogCacheGenerations.clear();
}
export function isVisiblePlugin(plugin: InstalledPluginRecord): boolean {
const od = (plugin.manifest?.od ?? {}) as Record<string, unknown>;
return od.hidden !== true;
}
export async function duplicatePluginAsProject(
pluginId: string,
input: { name?: string } = {},
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginDuplicateProjectResponse> {
const resp = await fetch(
`/api/plugins/${encodeURIComponent(pluginId)}/duplicate-project`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(input),
},
);
if (!resp.ok) {
throw new Error(await readErrorMessage(resp));
}
const json = (await resp.json()) as PluginDuplicateProjectResponse;
if (!json?.ok || !json.projectId) {
throw new Error('Could not duplicate this template.');
}
markProjectCreatedByViewer(json.projectId, workspaceContext ?? null);
return json;
}
interface PluginInstallEvent {
kind?: 'progress' | 'success' | 'error';
phase?: string;
message?: string;
code?: string;
plugin?: InstalledPluginRecord;
warnings?: string[];
}
export async function installPluginSource(
source: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginInstallOutcome> {
const log: string[] = [];
try {
const resp = await fetch('/api/plugins/install', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ source }),
});
if (!resp.ok) {
const message = await readErrorMessage(resp);
return { ok: false, warnings: [], message, status: resp.status, log };
}
if (!resp.body) {
return {
ok: false,
warnings: [],
message: 'Install stream did not start.',
log,
};
}
let success: InstalledPluginRecord | undefined;
let warnings: string[] = [];
let errorMessage: string | undefined;
let errorCode: string | undefined;
for await (const ev of readServerSentEvents(resp.body)) {
if (ev.message) log.push(ev.message);
if (ev.warnings) warnings = ev.warnings;
if (ev.kind === 'success') success = ev.plugin;
if (ev.kind === 'error') {
errorMessage = ev.message ?? 'Install failed.';
errorCode = boundedRequestErrorCode(ev.code);
}
}
return {
ok: Boolean(success) && !errorMessage,
plugin: success,
warnings,
message: errorMessage ?? (success ? `Installed ${success.title}.` : 'Install finished.'),
...(errorCode ? { errorCode } : {}),
log,
};
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
errorCode: 'network_error',
log,
};
}
}
export async function uploadPluginZip(file: File): Promise<PluginInstallOutcome> {
const form = new FormData();
form.append('file', file);
return postPluginUpload('/api/plugins/upload-zip', form);
}
export async function uploadPluginFolder(files: File[]): Promise<PluginInstallOutcome> {
const form = new FormData();
for (const file of files) {
const relativePath = getUploadRelativePath(file);
form.append('files', file, file.name);
form.append('paths', relativePath);
}
return postPluginUpload('/api/plugins/upload-folder', form);
}
export async function installGeneratedPluginFolder(
projectId: string,
relativePath: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginInstallOutcome> {
// Capture the account boundary before the request starts. If sign-in changes
// while the install is in flight, the successful response must evict the
// catalog that authorized this mutation, never the next account's cache.
const accountGeneration = currentWorkspaceAccountGeneration();
try {
const request: ProjectPluginFolderInstallRequest = { path: relativePath };
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/plugins/install-folder`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(request),
},
);
const outcome = await readPluginInstallOutcome(resp);
if (outcome.ok) {
// The event refreshes mounted consumers, but it is not durable: Home may
// be unmounted or identity-masked while a project installs its generated
// plugin. Evict the exact warm partition first so a later mount cannot
// reuse the pre-install catalog for the full TTL. Other Workspaces stay
// warm and avoid an unrelated refresh/performance regression.
invalidatePluginCatalogCache({
workspaceContext: workspaceContext ?? null,
accountGeneration,
});
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('open-design:plugins-changed'));
}
}
return outcome;
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
log: [],
};
}
}
export interface PluginShareOutcome {
ok: boolean;
message: string;
url?: string;
log?: string[];
code?: string;
}
export interface PluginShareTaskStart {
taskId: string;
action: 'publish-github' | 'contribute-open-design';
path: string;
status: 'queued' | 'running' | 'done' | 'failed';
startedAt: number;
}
export interface PluginShareTaskResult {
message: string;
url?: string;
log?: string[];
}
export interface PluginShareTaskError {
message: string;
code?: string;
log?: string[];
}
export interface PluginShareTaskSnapshot {
taskId: string;
action: 'publish-github' | 'contribute-open-design';
path: string;
status: 'queued' | 'running' | 'done' | 'failed';
startedAt: number;
endedAt?: number | null;
progress: string[];
nextSince: number;
result?: PluginShareTaskResult;
error?: PluginShareTaskError;
}
export async function publishGeneratedPluginToGitHub(
projectId: string,
relativePath: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareOutcome> {
return postGeneratedPluginShareAction(
projectId,
relativePath,
'publish-github',
workspaceContext,
);
}
export async function contributeGeneratedPluginToOpenDesign(
projectId: string,
relativePath: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareOutcome> {
return postGeneratedPluginShareAction(
projectId,
relativePath,
'contribute-open-design',
workspaceContext,
);
}
export async function startGeneratedPluginShareTask(
projectId: string,
relativePath: string,
action: 'publish-github' | 'contribute-open-design',
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareTaskStart> {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/plugins/share-tasks`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ path: relativePath, action }),
},
);
const body = await resp.json().catch(() => null) as Partial<PluginShareTaskStart> & {
error?: string | { message?: string };
message?: string;
} | null;
if (!resp.ok || !body?.taskId || !body?.action || !body?.path || !body?.status || !body?.startedAt) {
const errorMessage =
body?.message
?? (typeof body?.error === 'string' ? body.error : body?.error?.message)
?? 'Could not start plugin share task.';
throw new Error(errorMessage);
}
return {
taskId: body.taskId,
action: body.action,
path: body.path,
status: body.status,
startedAt: body.startedAt,
};
}
export async function waitGeneratedPluginShareTask(
taskId: string,
since: number,
timeoutMs = 25_000,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareTaskSnapshot> {
const resp = await fetch(`/api/plugins/share-tasks/${encodeURIComponent(taskId)}/wait`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ since, timeoutMs }),
});
const body = await resp.json().catch(() => null) as PluginShareTaskSnapshot & {
error?: string | { message?: string };
message?: string;
} | null;
if (!resp.ok || !body?.taskId) {
const errorMessage =
body?.message
?? (typeof body?.error === 'string' ? body.error : body?.error?.message)
?? 'Could not fetch plugin share task.';
throw new Error(errorMessage);
}
return body;
}
export type PluginShareProjectOutcome =
| (CreatePluginShareProjectResponse & { ok: true })
| {
ok: false;
message: string;
code?: string;
};
/**
* Start a plugin share task, which stands up a real chat project server-side.
*
* `workspaceContext` MUST be attached, for the same reason every sibling create
* in this file attaches it: the project this endpoint creates gets a
* `workspace_projects` binding from the request's own identity, and a headerless
* create leaves it unbound — its first run can use the signed-in account wallet,
* but it has no pinned Workspace for later Team billing or mutations. This call
* was the one create in this file with no `workspaceContext` parameter at all,
* so it was permanently unbound rather than merely racy.
*/
export async function createPluginShareProject(
pluginId: string,
action: PluginShareAction,
locale?: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareProjectOutcome> {
try {
const resp = await fetch(
`/api/plugins/${encodeURIComponent(pluginId)}/share-project`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({
action,
...(locale ? { locale } : {}),
}),
},
);
const body = (await resp.json().catch(() => null)) as
| (Partial<CreatePluginShareProjectResponse> & {
error?: string | { code?: string; message?: string };
code?: string;
})
| null;
if (resp.ok && body?.ok && body.project && body.conversationId) {
return body as CreatePluginShareProjectResponse & { ok: true };
}
const errorMessage =
typeof body?.error === 'string' ? body.error : body?.error?.message;
const fallbackMessage = resp.statusText || 'Could not create plugin share project.';
const message = body?.message ?? errorMessage ?? fallbackMessage;
const code =
body?.code ?? (typeof body?.error === 'object' ? body.error.code : undefined);
return {
ok: false,
message,
...(code ? { code } : {}),
};
} catch (err) {
return {
ok: false,
message: (err as Error).message,
};
}
}
async function postGeneratedPluginShareAction(
projectId: string,
relativePath: string,
action: 'publish-github' | 'contribute-open-design',
workspaceContext?: WorkspaceCollabContext | null,
): Promise<PluginShareOutcome> {
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/plugins/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ path: relativePath }),
},
);
const body = (await resp.json().catch(() => null)) as Partial<PluginShareOutcome> | null;
return {
ok: Boolean(resp.ok && body?.ok),
message: body?.message ?? (resp.ok ? 'Action finished.' : 'Plugin share action failed.'),
...(body?.url ? { url: body.url } : {}),
...(body?.log ? { log: body.log } : {}),
...(body?.code ? { code: body.code } : {}),
};
} catch (err) {
return {
ok: false,
message: (err as Error).message,
log: [],
};
}
}
export async function upgradePlugin(id: string): Promise<PluginInstallOutcome> {
const log: string[] = [];
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}/upgrade`, {
method: 'POST',
});
if (!resp.ok) {
const message = await readErrorMessage(resp);
return { ok: false, warnings: [], message, log };
}
if (!resp.body) {
return {
ok: false,
warnings: [],
message: 'Upgrade stream did not start.',
log,
};
}
let success: InstalledPluginRecord | undefined;
let warnings: string[] = [];
let errorMessage: string | undefined;
for await (const ev of readServerSentEvents(resp.body)) {
if (ev.message) log.push(ev.message);
if (ev.warnings) warnings = ev.warnings;
if (ev.kind === 'success') success = ev.plugin;
if (ev.kind === 'error') errorMessage = ev.message ?? 'Upgrade failed.';
}
return {
ok: Boolean(success) && !errorMessage,
plugin: success,
warnings,
message: errorMessage ?? (success ? `Upgraded ${success.title}.` : 'Upgrade finished.'),
log,
};
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
log,
};
}
}
async function postPluginUpload(url: string, form: FormData): Promise<PluginInstallOutcome> {
try {
const resp = await fetch(url, {
method: 'POST',
body: form,
});
const json = (await resp.json()) as Partial<PluginInstallOutcome> & {
error?: string | { code?: unknown; message?: string };
};
if (resp.ok && json.ok) {
return {
ok: true,
plugin: json.plugin,
warnings: json.warnings ?? [],
message: json.message ?? 'Plugin installed.',
log: json.log ?? [],
};
}
const message =
json.message ??
(typeof json.error === 'string' ? json.error : json.error?.message) ??
resp.statusText;
const errorCode = boundedRequestErrorCode(
json.errorCode ?? (typeof json.error === 'object' ? json.error?.code : undefined),
);
return {
ok: false,
warnings: json.warnings ?? [],
message,
status: resp.status,
...(errorCode ? { errorCode } : {}),
log: json.log ?? [],
};
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
errorCode: 'network_error',
log: [],
};
}
}
async function readPluginInstallOutcome(resp: Response): Promise<PluginInstallOutcome> {
const json = (await resp.json()) as Partial<PluginInstallOutcome> & {
error?: string | { message?: string };
};
if (resp.ok && json.ok) {
return {
ok: true,
...(json.plugin ? { plugin: json.plugin } : {}),
warnings: json.warnings ?? [],
message: json.message ?? 'Plugin installed.',
log: json.log ?? [],
};
}
const message =
json.message ??
(typeof json.error === 'string' ? json.error : json.error?.message) ??
resp.statusText;
return {
ok: false,
...(json.plugin ? { plugin: json.plugin } : {}),
warnings: json.warnings ?? [],
message,
log: json.log ?? [],
};
}
function getUploadRelativePath(file: File): string {
const withRelativePath = file as File & { webkitRelativePath?: string };
return withRelativePath.webkitRelativePath || file.name;
}
export async function uninstallPlugin(
id: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}/uninstall`, {
method: 'POST',
...(workspaceContext
? { headers: workspaceProjectHeaders(workspaceContext) }
: {}),
});
return resp.ok;
} catch {
return false;
}
}
export interface PluginMarketplace {
id: string;
url: string;
trust: PluginMarketplaceTrust;
specVersion?: string;
version?: string;
addedAt?: number;
refreshedAt?: number;
manifest: {
name?: string;
version?: string;
plugins?: PluginMarketplaceEntry[];
};
}
export type PluginMarketplaceTrust = 'official' | 'trusted' | 'restricted';
export interface PluginMarketplaceEntry {
name: string;
source: string;
version?: string;
ref?: string;
dist?: {
type?: string;
archive?: string;
integrity?: string;
manifestDigest?: string;
};
versions?: Array<{
version: string;
source?: string;
ref?: string;
dist?: {
type?: string;
archive?: string;
integrity?: string;
manifestDigest?: string;
};
integrity?: string;
manifestDigest?: string;
deprecated?: boolean | string;
yanked?: boolean;
yankedAt?: string;
yankReason?: string;
}>;
distTags?: Record<string, string>;
integrity?: string;
manifestDigest?: string;
publisher?: {
id?: string;
github?: string;
url?: string;
};
homepage?: string;
license?: string;
permissions?: string[];
capabilitiesSummary?: string[];
deprecated?: boolean | string;
yanked?: boolean;
yankedAt?: string;
yankReason?: string;
tags?: string[];
title?: string;
title_i18n?: Record<string, string>;
description?: string;
description_i18n?: Record<string, string>;
icon?: string;
}
export interface PluginMarketplaceMutationOutcome {
ok: boolean;
marketplace?: PluginMarketplace;
message: string;
}
export async function listPluginMarketplaces(): Promise<PluginMarketplace[]> {
try {
const resp = await fetch('/api/marketplaces');
if (!resp.ok) return [];
const json = (await resp.json()) as { marketplaces?: PluginMarketplace[] };
return json.marketplaces ?? [];
} catch {
return [];
}
}
export async function addPluginMarketplace(input: {
url: string;
trust: PluginMarketplaceTrust;
}): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch('/api/marketplaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return readPluginMarketplaceOutcome(resp, 'Marketplace source added.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function refreshPluginMarketplace(
id: string,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, {
method: 'POST',
});
return readPluginMarketplaceOutcome(resp, 'Marketplace source refreshed.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function removePluginMarketplace(
id: string,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!resp.ok) {
return { ok: false, message: await readErrorMessage(resp) };
}
return { ok: true, message: 'Marketplace source removed.' };
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
export async function setPluginMarketplaceTrust(
id: string,
trust: PluginMarketplaceTrust,
): Promise<PluginMarketplaceMutationOutcome> {
try {
const resp = await fetch(`/api/marketplaces/${encodeURIComponent(id)}/trust`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trust }),
});
return readPluginMarketplaceOutcome(resp, 'Marketplace trust updated.');
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
async function readPluginMarketplaceOutcome(
resp: Response,
successMessage: string,
): Promise<PluginMarketplaceMutationOutcome> {
if (!resp.ok) {
return { ok: false, message: await readErrorMessage(resp) };
}
const marketplace = (await resp.json().catch(() => null)) as PluginMarketplace | null;
return {
ok: true,
...(marketplace ? { marketplace } : {}),
message: successMessage,
};
}
export async function applyPlugin(
pluginId: string,
options: {
inputs?: Record<string, unknown>;
projectId?: string;
grantCaps?: string[];
locale?: string;
pluginSource?: string;
workspaceContext?: WorkspaceCollabContext | null;
} = {},
): Promise<ApplyResult | null> {
try {
const requestBody = JSON.stringify({
...(options.pluginSource ? { source: options.pluginSource } : {}),
inputs: options.inputs ?? {},
projectId: options.projectId,
grantCaps: options.grantCaps ?? [],
locale: options.locale,
});
const pluginUrl = `/api/plugins/${encodeURIComponent(pluginId)}`;
let resp = await fetch(
`${pluginUrl}/${options.pluginSource ? 'apply-local' : 'apply'}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(!options.pluginSource && options.workspaceContext
? workspaceProjectHeaders(options.workspaceContext)
: {}),
},
body: requestBody,
},
);
// Exact-source requests must never degrade to the legacy ID-only route:
// its response cannot prove which same-ID local record it applied. New
// daemons still accept old ID-only clients through /apply; during the brief
// new-Web/old-daemon upgrade window, omitting the plugin is safer than
// silently substituting different local bytes.
if (!resp.ok) return null;
const json = (await resp.json()) as ApplyResult & { ok?: boolean };
return json;
} catch {
return null;
}
}
async function readErrorMessage(resp: Response): Promise<string> {
try {
const json = (await resp.json()) as {
error?: string | { message?: string; data?: { errors?: unknown } };
errors?: unknown;
message?: string;
};
const message =
json.message ??
(typeof json.error === 'string' ? json.error : json.error?.message);
const details = extractErrorDetails(
typeof json.error === 'object' ? json.error.data?.errors : undefined,
json.errors,
);
if (message && details.length > 0) return `${message}: ${details.join('; ')}`;
if (message) return message;
} catch {
// Fall through to the status text below.
}
return resp.statusText || `HTTP ${resp.status}`;
}
function extractErrorDetails(...values: unknown[]): string[] {
return values.flatMap((value) => {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (typeof item === 'string' && item.trim()) return [item.trim()];
if (item && typeof item === 'object' && 'message' in item) {
const message = (item as { message?: unknown }).message;
if (typeof message === 'string' && message.trim()) return [message.trim()];
}
return [];
});
});
}
async function* readServerSentEvents(
body: ReadableStream<Uint8Array>,
): AsyncGenerator<PluginInstallEvent, void, void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(/\n\n/);
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = parseServerSentEvent(part);
if (event) yield event;
}
}
buffer += decoder.decode();
const event = parseServerSentEvent(buffer);
if (event) yield event;
} finally {
reader.releaseLock();
}
}
function parseServerSentEvent(raw: string): PluginInstallEvent | null {
const data = raw
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n');
if (!data) return null;
try {
return JSON.parse(data) as PluginInstallEvent;
} catch {
return null;
}
}
// Fetch the immutable snapshot pinned to a project / conversation.
// Used by ProjectView to surface the active plugin as a context chip
// on user messages instead of re-rendering the inline plugin rail
// (the user already picked a plugin on Home — re-prompting is noise).
export async function fetchAppliedPluginSnapshot(
snapshotId: string,
): Promise<AppliedPluginSnapshot | null> {
try {
const resp = await fetch(
`/api/applied-plugins/${encodeURIComponent(snapshotId)}`,
);
if (!resp.ok) return null;
return (await resp.json()) as AppliedPluginSnapshot;
} catch {
return null;
}
}
// Render the brief that the composer should display for the active
// applied plugin. Substitutes `{{var}}` placeholders inside
// useCase.query against the user-supplied inputs map; missing values
// stay as `{{var}}` so the gating "fill required" hint stays visible.
export function renderPluginBriefTemplate(
template: string,
inputs: Record<string, unknown>,
): string {
return template.replace(/\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g, (full, key) => {
if (key in inputs) {
const v = inputs[key];
if (v === undefined || v === null || v === '') return full;
return String(v);
}
return full;
});
}
export function resolvePluginQueryFallback(
value: unknown,
locale?: string,
fallbackLocale: string = 'en',
): string {
if (!value) return '';
if (typeof value === 'string') return value;
if (!isStringMap(value)) return '';
const candidates = [
locale,
locale?.split('-')[0],
fallbackLocale,
fallbackLocale.split('-')[0],
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
const resolved = value[candidate];
if (typeof resolved === 'string' && resolved.length > 0) return resolved;
}
return Object.values(value).find((entry) => entry.length > 0) ?? '';
}
function isStringMap(value: unknown): value is Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return Object.values(value).every((entry) => typeof entry === 'string');
}