mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
Move local workspace ownership into OpenWork server (#1107)
* feat(server): move local workspace ownership off Tauri * docs: clarify architecture authority for agents * fix: harden workspace reconnect and sidebar navigation * feat(app): soften get-started copy in workspace onboarding * fix(app): stabilize starter workspace onboarding * fix(app): normalize local workspace paths
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -38,7 +38,7 @@ User mental model:
|
||||
* A worker is a remote runtime destination.
|
||||
* Connecting to a worker happens through `Add worker` -> `Connect remote` using URL + token (or deep link).
|
||||
|
||||
Read INFRASTRUCTURE.md
|
||||
Read `ARCHITECTURE.md` for runtime flow, server-vs-shell ownership, and architecture behavior. Read `INFRASTRUCTURE.md` for deployment and control-plane details.
|
||||
|
||||
## Why OpenWork Exists
|
||||
|
||||
@@ -126,12 +126,13 @@ Design principles for hot reload:
|
||||
## Repository Guidance
|
||||
|
||||
* Use `VISION.md`, `PRINCIPLES.md`, `PRODUCT.md`, `ARCHITECTURE.md`, and `INFRASTRUCTURE.md` to understand the "why" and requirements so you can guide your decisions.
|
||||
* Treat `ARCHITECTURE.md` as the authoritative system design source for runtime flow, server ownership, filesystem mutation policy, and agent/runtime boundaries. If those behaviors change, update `ARCHITECTURE.md` in the same task.
|
||||
* Use `DESIGN-LANGUAGE.md` as the default visual reference for OpenWork app and landing work.
|
||||
* For OpenWork session-surface details, also reference `packages/docs/orbita-layout-style.mdx`.
|
||||
|
||||
## Dev Debugging
|
||||
|
||||
* If you change `packages/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources.
|
||||
* If you change `apps/server/src`, rebuild the OpenWork server binary (`pnpm --filter openwork-server build:bin`) because `openwork` (openwork-orchestrator) runs the compiled server, not the TS sources.
|
||||
|
||||
## Local Structure
|
||||
|
||||
@@ -144,7 +145,7 @@ openwork/
|
||||
ARCHITECTURE.md # Runtime modes and OpenCode integration
|
||||
.gitignore # Ignores vendor/opencode, node_modules, etc.
|
||||
.opencode/
|
||||
packages/
|
||||
apps/
|
||||
app/
|
||||
src/
|
||||
public/
|
||||
@@ -154,6 +155,9 @@ openwork/
|
||||
desktop/
|
||||
src-tauri/
|
||||
package.json
|
||||
server/
|
||||
src/
|
||||
package.json
|
||||
```
|
||||
|
||||
## OpenCode SDK Usage
|
||||
@@ -190,7 +194,7 @@ Key primitives to expose:
|
||||
|
||||
## Skill: SolidJS Patterns
|
||||
|
||||
When editing SolidJS UI (`packages/app/src/**/*.tsx`), consult:
|
||||
When editing SolidJS UI (`apps/app/src/**/*.tsx`), consult:
|
||||
|
||||
* `.opencode/skills/solidjs-patterns/SKILL.md`
|
||||
|
||||
@@ -206,11 +210,11 @@ OpenWork releases are built by GitHub Actions (`Release App`). A release is trig
|
||||
1. Ensure `main` is green and up to date.
|
||||
2. Bump versions (keep these in sync):
|
||||
|
||||
* `packages/app/package.json` (`version`)
|
||||
* `packages/desktop/package.json` (`version`)
|
||||
* `packages/orchestrator/package.json` (`version`, publishes as `openwork-orchestrator`)
|
||||
* `packages/desktop/src-tauri/tauri.conf.json` (`version`)
|
||||
* `packages/desktop/src-tauri/Cargo.toml` (`version`)
|
||||
* `apps/app/package.json` (`version`)
|
||||
* `apps/desktop/package.json` (`version`)
|
||||
* `apps/orchestrator/package.json` (`version`, publishes as `openwork-orchestrator`)
|
||||
* `apps/desktop/src-tauri/tauri.conf.json` (`version`)
|
||||
* `apps/desktop/src-tauri/Cargo.toml` (`version`)
|
||||
|
||||
You can bump all three non-interactively with:
|
||||
|
||||
@@ -244,11 +248,11 @@ Confirm the DMG assets are attached and versioned correctly.
|
||||
This is usually covered by `Release App` when `publish_sidecars` + `publish_npm` are enabled. Use `.opencode/skills/openwork-orchestrator-npm-publish/SKILL.md` for manual recovery or one-off publishing.
|
||||
|
||||
1. Ensure the default branch is up to date and clean.
|
||||
2. Bump `packages/orchestrator/package.json` (`version`).
|
||||
2. Bump `apps/orchestrator/package.json` (`version`).
|
||||
3. Commit the bump.
|
||||
4. Build and upload sidecar assets for the same version tag:
|
||||
* `pnpm --filter openwork-orchestrator build:sidecars`
|
||||
* `gh release create openwork-orchestrator-vX.Y.Z packages/orchestrator/dist/sidecars/* --repo different-ai/openwork`
|
||||
* `gh release create openwork-orchestrator-vX.Y.Z apps/orchestrator/dist/sidecars/* --repo different-ai/openwork`
|
||||
5. Publish:
|
||||
* `pnpm --filter openwork-orchestrator publish --access public`
|
||||
6. Verify:
|
||||
|
||||
@@ -30,6 +30,11 @@ Auto-detection can exist as a convenience, but should be tiered and explainable:
|
||||
|
||||
The readiness check should be a clear, single command (e.g. `docker info`) and the UI should show the exact error output when it fails.
|
||||
|
||||
## Minimal use of Tauri
|
||||
We move most of the functionality to the openwork server which interfaces mostly with FS and proxies to opencode.
|
||||
|
||||
|
||||
|
||||
## Filesystem mutation policy
|
||||
|
||||
OpenWork should route filesystem mutations through the OpenWork server whenever possible.
|
||||
@@ -47,6 +52,36 @@ Guidelines:
|
||||
- If a feature cannot yet write through the OpenWork server, treat that as an architecture gap and close it before depending on direct local writes.
|
||||
- Reads can fall back locally when necessary, but writes should be designed around the OpenWork server path.
|
||||
|
||||
## Agent authority map
|
||||
|
||||
When OpenWork is edited from `openwork-enterprise`, architecture and runtime behavior should be sourced from this document.
|
||||
|
||||
| Entry point | Role | Architecture authority |
|
||||
| --- | --- | --- |
|
||||
| `openwork-enterprise/AGENTS.md` | OpenWork Factory multi-repo orchestration | Defers OpenWork runtime flow, server-vs-shell ownership, and filesystem mutation behavior to `_repos/openwork/ARCHITECTURE.md`. |
|
||||
| `openwork-enterprise/.opencode/agents/openwork-surgeon.md` | Surgical fix agent for `_repos/openwork` | Uses `_repos/openwork/ARCHITECTURE.md` as the runtime and architecture source of truth before changing product behavior. |
|
||||
| `_repos/openwork/AGENTS.md` | Product vocabulary, audience, and repo-local development guidance | Refers to `ARCHITECTURE.md` for runtime flow, server ownership, and architectural boundaries. |
|
||||
| Skills / commands / agents that mutate workspace state | Capability layer on top of the product runtime | Should assume the OpenWork server path is canonical for workspace creation, config writes, `.opencode/` mutation, and reload signaling. |
|
||||
|
||||
### Agent access to server-owned behavior
|
||||
|
||||
Agents, skills, and commands should model the following as OpenWork server behavior first:
|
||||
|
||||
- workspace creation and initialization
|
||||
- writes to `.opencode/`, `opencode.json`, and `opencode.jsonc`
|
||||
- OpenWork workspace config writes (`.opencode/openwork.json`)
|
||||
- reload event generation after config or capability changes
|
||||
- other filesystem-backed capability changes that must work across desktop host mode and remote clients
|
||||
|
||||
Tauri or other native shell behavior remains the fallback or shell boundary for:
|
||||
|
||||
- file and folder picking
|
||||
- reveal/open-in-OS affordances
|
||||
- updater and window management
|
||||
- host-side process supervision and native runtime bootstrapping
|
||||
|
||||
If an agent needs one of the server-owned behaviors above and only a Tauri path exists, treat that as an architecture gap to close rather than a parallel capability surface to preserve.
|
||||
|
||||
## opencode primitives
|
||||
how to pick the right extension abstraction for
|
||||
@opencode
|
||||
|
||||
@@ -3023,6 +3023,8 @@ export default function App() {
|
||||
openworkServerSettings,
|
||||
updateOpenworkServerSettings,
|
||||
openworkServerClient,
|
||||
openworkServerStatus,
|
||||
openworkServerWorkspaceId,
|
||||
onEngineStable: () => {},
|
||||
engineRuntime,
|
||||
developerMode,
|
||||
@@ -3324,6 +3326,26 @@ export default function App() {
|
||||
? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot)
|
||||
: allSessions;
|
||||
const sorted = sortSessionsByActivity(scopedSessions);
|
||||
if (developerMode()) {
|
||||
console.log("[sidebar-sync] workspace session scope", {
|
||||
wsId,
|
||||
status,
|
||||
activeWorkspace,
|
||||
activeWorkspaceRoot,
|
||||
allSessions: allSessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
parentID: session.parentID,
|
||||
})),
|
||||
scopedSessions: scopedSessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
parentID: session.parentID,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const rootItems: SidebarSessionItem[] = sorted.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
@@ -3421,6 +3443,22 @@ export default function App() {
|
||||
}
|
||||
return dedupedWorkspaces.map((workspace) => {
|
||||
const groupSessions = sessionsById[workspace.id] ?? [];
|
||||
if (developerMode()) {
|
||||
console.log("[sidebar-groups] workspace group", {
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceType: workspace.workspaceType,
|
||||
workspacePath: workspace.path,
|
||||
workspaceDirectory: workspace.directory,
|
||||
sessionCount: groupSessions.length,
|
||||
sessions: groupSessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
parentID: session.parentID,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
workspace,
|
||||
sessions: groupSessions,
|
||||
@@ -3518,7 +3556,7 @@ export default function App() {
|
||||
if (cancelled) return;
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === root);
|
||||
setOpenworkServerWorkspaceId(match?.id ?? response.activeId ?? null);
|
||||
setOpenworkServerWorkspaceId(match?.id ?? null);
|
||||
} catch {
|
||||
if (!cancelled) setOpenworkServerWorkspaceId(null);
|
||||
}
|
||||
@@ -3608,7 +3646,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const findSharedBundleImportWorkspaceId = (
|
||||
items: Array<{ id: string; path?: string; directory?: string; opencode?: { directory?: string } }>,
|
||||
items: Array<{ id: string; path?: string | null; directory?: string | null; opencode?: { directory?: string | null } }>,
|
||||
target?: SharedBundleImportTarget,
|
||||
) => {
|
||||
const explicitId = target?.workspaceId?.trim() ?? "";
|
||||
@@ -4903,8 +4941,6 @@ export default function App() {
|
||||
|
||||
// Scheduler helpers - must be defined after workspaceStore
|
||||
const resolveOpenworkScheduler = () => {
|
||||
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
|
||||
if (!isRemoteWorkspace) return null;
|
||||
const client = openworkServerClient();
|
||||
const workspaceId = openworkServerWorkspaceId();
|
||||
if (openworkServerStatus() !== "connected" || !client || !workspaceId) return null;
|
||||
@@ -4912,7 +4948,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const scheduledJobsSource = createMemo<"local" | "remote">(() => {
|
||||
return workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local";
|
||||
return resolveOpenworkScheduler() ? "remote" : "local";
|
||||
});
|
||||
|
||||
const scheduledJobsSourceReady = createMemo(() => {
|
||||
@@ -5620,6 +5656,25 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const readMcpConfigFile = async (scope: "project" | "global") => {
|
||||
const projectDir = workspaceProjectDir().trim();
|
||||
const openworkClient = openworkServerClient();
|
||||
const openworkWorkspaceId = openworkServerWorkspaceId();
|
||||
const canUseOpenworkServer =
|
||||
openworkServerStatus() === "connected" &&
|
||||
openworkClient &&
|
||||
openworkWorkspaceId &&
|
||||
resolvedOpenworkCapabilities()?.config?.read;
|
||||
|
||||
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
|
||||
return openworkClient.readOpencodeConfigFile(openworkWorkspaceId, scope);
|
||||
}
|
||||
if (!isTauriRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return readOpencodeConfig(scope, projectDir);
|
||||
};
|
||||
|
||||
async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) {
|
||||
const startedAt = perfNow();
|
||||
const isRemoteWorkspace =
|
||||
@@ -7109,6 +7164,10 @@ export default function App() {
|
||||
setTab("settings");
|
||||
setView("dashboard");
|
||||
},
|
||||
onOpenAdvancedSettings: () => {
|
||||
setTab("config");
|
||||
setView("dashboard");
|
||||
},
|
||||
themeMode: themeMode(),
|
||||
setThemeMode,
|
||||
});
|
||||
@@ -7217,6 +7276,8 @@ export default function App() {
|
||||
testWorkspaceConnection: workspaceStore.testWorkspaceConnection,
|
||||
recoverWorkspace: workspaceStore.recoverWorkspace,
|
||||
openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true),
|
||||
getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow,
|
||||
pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder,
|
||||
openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true),
|
||||
connectRemoteWorkspace: workspaceStore.createRemoteWorkspaceFlow,
|
||||
importWorkspaceConfig: workspaceStore.importWorkspaceConfig,
|
||||
@@ -7391,6 +7452,7 @@ export default function App() {
|
||||
mcpConnectingName: mcpConnectingName(),
|
||||
selectedMcp: selectedMcp(),
|
||||
setSelectedMcp,
|
||||
readConfigFile: readMcpConfigFile,
|
||||
quickConnect: MCP_QUICK_CONNECT,
|
||||
connectMcp,
|
||||
authorizeMcp,
|
||||
@@ -7449,6 +7511,8 @@ export default function App() {
|
||||
editWorkspaceConnection: openWorkspaceConnectionSettings,
|
||||
forgetWorkspace: workspaceStore.forgetWorkspace,
|
||||
openCreateWorkspace: () => workspaceStore.setCreateWorkspaceOpen(true),
|
||||
getStartedWorkspace: workspaceStore.quickStartWorkspaceFlow,
|
||||
pickFolderWorkspace: workspaceStore.createWorkspaceFromPickedFolder,
|
||||
openCreateRemoteWorkspace: () => workspaceStore.setCreateRemoteWorkspaceOpen(true),
|
||||
importWorkspaceConfig: workspaceStore.importWorkspaceConfig,
|
||||
importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
@@ -30,6 +31,7 @@ type Props = {
|
||||
onOpenAutomations: () => void;
|
||||
onOpenSkills: () => void;
|
||||
onOpenExtensions: () => void;
|
||||
onOpenMessaging: () => void;
|
||||
onOpenAdvanced: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onInboxToast?: (message: string) => void;
|
||||
@@ -117,6 +119,12 @@ export default function WorkspaceRightSidebar(props: Props) {
|
||||
showSelection() && (props.tab === "mcp" || props.tab === "plugins"),
|
||||
props.onOpenExtensions,
|
||||
)}
|
||||
{sidebarButton(
|
||||
"Messaging",
|
||||
<MessageCircle size={18} />,
|
||||
showSelection() && props.tab === "identities",
|
||||
props.onOpenMessaging,
|
||||
)}
|
||||
<Show when={props.developerMode}>
|
||||
{sidebarButton(
|
||||
"Advanced",
|
||||
|
||||
@@ -686,9 +686,24 @@ export function createSessionStore(options: {
|
||||
// string equality against the stored session.directory.
|
||||
const queryDirectory = normalizeDirectoryQueryPath(scopeRoot) || undefined;
|
||||
|
||||
sessionDebug("sessions:load:request", {
|
||||
scopeRoot: scopeRoot ?? null,
|
||||
queryDirectory: queryDirectory ?? null,
|
||||
activeWorkspaceRoot: options.activeWorkspaceRoot?.() ?? null,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
sessionDebug("sessions:load:start", { scopeRoot: scopeRoot ?? null, queryDirectory: queryDirectory ?? null });
|
||||
const list = unwrap(await c.session.list({ directory: queryDirectory, roots: true }));
|
||||
sessionDebug("sessions:load:response", {
|
||||
count: list.length,
|
||||
sessions: list.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
parentID: session.parentID,
|
||||
})),
|
||||
});
|
||||
sessionDebug("sessions:load:raw", { count: list.length, ms: Date.now() - start });
|
||||
|
||||
// Defensive client-side filter in case the server returns sessions spanning
|
||||
@@ -697,6 +712,16 @@ export function createSessionStore(options: {
|
||||
const filtered = root
|
||||
? list.filter((session) => normalizeDirectoryPath(session.directory) === root)
|
||||
: list;
|
||||
sessionDebug("sessions:load:filtered-list", {
|
||||
root: root || null,
|
||||
count: filtered.length,
|
||||
sessions: filtered.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
parentID: session.parentID,
|
||||
})),
|
||||
});
|
||||
sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length });
|
||||
rememberSessions(filtered);
|
||||
setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
OpenworkServerError,
|
||||
type OpenworkServerClient,
|
||||
type OpenworkServerSettings,
|
||||
type OpenworkServerStatus,
|
||||
type OpenworkWorkspaceInfo,
|
||||
} from "../lib/openwork-server";
|
||||
import { downloadDir, homeDir } from "@tauri-apps/api/path";
|
||||
@@ -145,6 +146,8 @@ export function createWorkspaceStore(options: {
|
||||
openworkServerSettings: () => OpenworkServerSettings;
|
||||
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
|
||||
openworkServerClient?: () => OpenworkServerClient | null;
|
||||
openworkServerStatus?: () => OpenworkServerStatus;
|
||||
openworkServerWorkspaceId?: () => string | null;
|
||||
setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void;
|
||||
onEngineStable?: () => void;
|
||||
engineRuntime?: () => EngineRuntime;
|
||||
@@ -189,6 +192,9 @@ export function createWorkspaceStore(options: {
|
||||
const LONG_BOOT_CONNECT_REASONS = new Set(["host-start", "bootstrap-local"]);
|
||||
const INITIAL_WORKSPACE_SETUP_COMPLETE_KEY = "openwork.initialWorkspaceSetupComplete";
|
||||
const LEGACY_ONBOARDING_COMPLETE_KEY = "openwork.onboardingComplete";
|
||||
const STARTER_BOOTSTRAP_STATE_KEY = "openwork.starterBootstrapState";
|
||||
const STARTER_BOOTSTRAP_FOLDER_NAME = "OpenWork";
|
||||
const STARTER_BOOTSTRAP_WORKSPACE_NAME = "starter";
|
||||
const DB_MIGRATE_UNSUPPORTED_PATTERNS = [
|
||||
/unknown(?:\s+sub)?command\s+['"`]?db['"`]?/i,
|
||||
/unrecognized(?:\s+sub)?command\s+['"`]?db['"`]?/i,
|
||||
@@ -317,17 +323,59 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
};
|
||||
|
||||
type StarterBootstrapState = "not_started" | "in_progress" | "completed" | "failed" | "skipped";
|
||||
|
||||
const readStarterBootstrapState = (): StarterBootstrapState => {
|
||||
if (typeof window === "undefined") return "not_started";
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STARTER_BOOTSTRAP_STATE_KEY);
|
||||
if (raw === "in_progress" || raw === "completed" || raw === "failed" || raw === "skipped") {
|
||||
return raw;
|
||||
}
|
||||
return "not_started";
|
||||
} catch {
|
||||
return "not_started";
|
||||
}
|
||||
};
|
||||
|
||||
const persistStarterBootstrapState = (next: StarterBootstrapState) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STARTER_BOOTSTRAP_STATE_KEY, next);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const [projectDir, setProjectDir] = createSignal("");
|
||||
const [workspaces, setWorkspaces] = createSignal<WorkspaceInfo[]>([]);
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = createSignal<string>("");
|
||||
const [initialWorkspaceSetupComplete, setInitialWorkspaceSetupComplete] = createSignal(
|
||||
readInitialWorkspaceSetupComplete(),
|
||||
);
|
||||
const [starterBootstrapState, setStarterBootstrapState] = createSignal<StarterBootstrapState>(
|
||||
readStarterBootstrapState(),
|
||||
);
|
||||
|
||||
const syncActiveWorkspaceId = (id: string) => {
|
||||
setActiveWorkspaceId(id);
|
||||
};
|
||||
|
||||
const applyServerLocalWorkspaces = (nextLocals: WorkspaceInfo[], nextActiveId: string | null | undefined) => {
|
||||
const remotes = workspaces().filter((workspace) => workspace.workspaceType === "remote");
|
||||
const merged = [...nextLocals, ...remotes];
|
||||
setWorkspaces(merged);
|
||||
|
||||
const currentActiveId = activeWorkspaceId();
|
||||
const fallbackActiveId = merged.some((workspace) => workspace.id === currentActiveId)
|
||||
? currentActiveId
|
||||
: merged[0]?.id ?? "";
|
||||
const resolvedActiveId = nextActiveId?.trim() || fallbackActiveId;
|
||||
if (resolvedActiveId) {
|
||||
syncActiveWorkspaceId(resolvedActiveId);
|
||||
}
|
||||
};
|
||||
|
||||
const [authorizedDirs, setAuthorizedDirs] = createSignal<string[]>([]);
|
||||
const [newAuthorizedDir, setNewAuthorizedDir] = createSignal("");
|
||||
|
||||
@@ -348,6 +396,11 @@ export function createWorkspaceStore(options: {
|
||||
const firstRunWorkspaceSetup = createMemo(
|
||||
() => isTauriRuntime() && !initialWorkspaceSetupComplete() && workspaces().length === 0,
|
||||
);
|
||||
const setPersistedStarterBootstrapState = (next: StarterBootstrapState) => {
|
||||
setStarterBootstrapState(next);
|
||||
persistStarterBootstrapState(next);
|
||||
};
|
||||
|
||||
const activeWorkspaceDisplay = createMemo<WorkspaceDisplay>(() => {
|
||||
const ws = activeWorkspaceInfo();
|
||||
if (!ws) {
|
||||
@@ -554,18 +607,55 @@ export function createWorkspaceStore(options: {
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const activateOpenworkHostWorkspace = async (workspacePath: string) => {
|
||||
const resolveConnectedOpenworkServer = () => {
|
||||
const client = options.openworkServerClient?.();
|
||||
if (!client) return;
|
||||
if (!client) return null;
|
||||
if (options.openworkServerStatus?.() !== "connected") return null;
|
||||
return client;
|
||||
};
|
||||
|
||||
const resolveActiveOpenworkWorkspace = () => {
|
||||
const client = resolveConnectedOpenworkServer();
|
||||
const workspaceId = options.openworkServerWorkspaceId?.()?.trim() ?? "";
|
||||
if (!client || !workspaceId) return null;
|
||||
return { client, workspaceId };
|
||||
};
|
||||
|
||||
const findOpenworkWorkspaceByPath = async (workspacePath: string) => {
|
||||
const client = resolveConnectedOpenworkServer();
|
||||
const targetPath = normalizeDirectoryPath(workspacePath);
|
||||
if (!targetPath) return;
|
||||
if (!client || !targetPath) return null;
|
||||
|
||||
const response = await client.listWorkspaces();
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath);
|
||||
if (!match?.id) return null;
|
||||
return { client, workspaceId: match.id, response };
|
||||
};
|
||||
|
||||
const loadWorkspaceConfigFromOpenworkServer = async (workspacePath: string): Promise<WorkspaceOpenworkConfig | null> => {
|
||||
const resolved = await findOpenworkWorkspaceByPath(workspacePath);
|
||||
if (!resolved) return null;
|
||||
if (resolved.response.activeId !== resolved.workspaceId) {
|
||||
await resolved.client.activateWorkspace(resolved.workspaceId);
|
||||
}
|
||||
const config = await resolved.client.getConfig(resolved.workspaceId);
|
||||
return (config.openwork as WorkspaceOpenworkConfig | null | undefined) ?? null;
|
||||
};
|
||||
|
||||
const persistWorkspaceConfigToOpenworkServer = async (config: WorkspaceOpenworkConfig): Promise<boolean> => {
|
||||
const active = resolveActiveOpenworkWorkspace();
|
||||
if (!active) return false;
|
||||
await active.client.patchConfig(active.workspaceId, { openwork: config as Record<string, unknown> });
|
||||
return true;
|
||||
};
|
||||
|
||||
const activateOpenworkHostWorkspace = async (workspacePath: string) => {
|
||||
const resolved = await findOpenworkWorkspaceByPath(workspacePath);
|
||||
if (!resolved) return;
|
||||
try {
|
||||
const response = await client.listWorkspaces();
|
||||
const items = Array.isArray(response.items) ? response.items : [];
|
||||
const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath);
|
||||
if (!match?.id) return;
|
||||
if (response.activeId === match.id) return;
|
||||
await client.activateWorkspace(match.id);
|
||||
if (resolved.response.activeId === resolved.workspaceId) return;
|
||||
await resolved.client.activateWorkspace(resolved.workspaceId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -994,7 +1084,8 @@ export function createWorkspaceStore(options: {
|
||||
} else {
|
||||
setWorkspaceConfigLoaded(false);
|
||||
try {
|
||||
const cfg = await workspaceOpenworkRead({ workspacePath: next.path });
|
||||
const cfg = await loadWorkspaceConfigFromOpenworkServer(next.path)
|
||||
?? await workspaceOpenworkRead({ workspacePath: next.path });
|
||||
setWorkspaceConfig(cfg);
|
||||
setWorkspaceConfigLoaded(true);
|
||||
|
||||
@@ -1012,6 +1103,9 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
await activateOpenworkHostWorkspace(next.path);
|
||||
}
|
||||
await workspaceSetActive(id);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -1427,6 +1521,13 @@ export function createWorkspaceStore(options: {
|
||||
|
||||
const openEmptySession = async (scopeRoot?: string) => {
|
||||
const root = (scopeRoot ?? activeWorkspaceRoot().trim()).trim();
|
||||
wsDebug("open-empty-session:start", {
|
||||
scopeRoot: scopeRoot ?? null,
|
||||
resolvedRoot: root || null,
|
||||
activeWorkspaceId: activeWorkspaceId(),
|
||||
activeWorkspace: activeWorkspaceInfo(),
|
||||
hasClient: Boolean(options.client()),
|
||||
});
|
||||
|
||||
if (options.client()) {
|
||||
try {
|
||||
@@ -1488,22 +1589,34 @@ export function createWorkspaceStore(options: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
|
||||
const ws = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
setWorkspaces(ws.workspaces);
|
||||
syncActiveWorkspaceId(ws.activeId);
|
||||
const name = deriveWorkspaceName(resolvedFolder, preset);
|
||||
const openworkServer = resolveConnectedOpenworkServer();
|
||||
const ws = openworkServer
|
||||
? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset })
|
||||
: await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
|
||||
if (openworkServer && isTauriRuntime()) {
|
||||
try {
|
||||
await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
} catch {
|
||||
// keep the server result as the source of truth for this run
|
||||
}
|
||||
}
|
||||
|
||||
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
|
||||
if (ws.activeId) {
|
||||
updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null });
|
||||
}
|
||||
|
||||
setCreateWorkspaceOpen(false);
|
||||
markOnboardingComplete();
|
||||
|
||||
const opened = await activateFreshLocalWorkspace(ws.activeId ?? null, resolvedFolder);
|
||||
if (!opened) {
|
||||
return false;
|
||||
}
|
||||
|
||||
markOnboardingComplete();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
@@ -1599,24 +1712,45 @@ export function createWorkspaceStore(options: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = resolvedFolder.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
|
||||
const name = deriveWorkspaceName(resolvedFolder, preset);
|
||||
|
||||
setSandboxStep("workspace", { status: "active", detail: name });
|
||||
pushSandboxCreateLog(`Worker: ${resolvedFolder}`);
|
||||
|
||||
// Ensure the workspace folder has baseline OpenWork/OpenCode files.
|
||||
const created = await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
setWorkspaces(created.workspaces);
|
||||
syncActiveWorkspaceId(created.activeId);
|
||||
const openworkServer = resolveConnectedOpenworkServer();
|
||||
const created = openworkServer
|
||||
? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset })
|
||||
: await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
if (openworkServer && isTauriRuntime()) {
|
||||
try {
|
||||
await workspaceCreate({ folderPath: resolvedFolder, name, preset });
|
||||
} catch {
|
||||
// ignore desktop mirror failures here
|
||||
}
|
||||
}
|
||||
applyServerLocalWorkspaces(created.workspaces, created.activeId);
|
||||
setSandboxStep("workspace", { status: "done", detail: null });
|
||||
|
||||
// Remove the local workspace entry to avoid duplicate Local+Remote rows.
|
||||
const localId = created.activeId;
|
||||
if (localId) {
|
||||
pushSandboxCreateLog("Removing local worker row (will re-add as remote sandbox)...");
|
||||
const forgotten = await workspaceForget(localId);
|
||||
setWorkspaces(forgotten.workspaces);
|
||||
syncActiveWorkspaceId(forgotten.activeId);
|
||||
const activeLocalWorkspace = openworkServer ? await findOpenworkWorkspaceByPath(resolvedFolder) : null;
|
||||
const forgotten = await (activeLocalWorkspace
|
||||
? (() => activeLocalWorkspace.client.deleteWorkspace(activeLocalWorkspace.workspaceId).then((response) => ({
|
||||
activeId: response.activeId ?? "",
|
||||
workspaces: response.workspaces ?? response.items,
|
||||
})))()
|
||||
: workspaceForget(localId));
|
||||
if (activeLocalWorkspace && isTauriRuntime()) {
|
||||
try {
|
||||
await workspaceForget(localId);
|
||||
} catch {
|
||||
// ignore desktop mirror failures here
|
||||
}
|
||||
}
|
||||
applyServerLocalWorkspaces(forgotten.workspaces, forgotten.activeId);
|
||||
}
|
||||
|
||||
setSandboxStep("sandbox", { status: "active", detail: null });
|
||||
@@ -2130,15 +2264,38 @@ export function createWorkspaceStore(options: {
|
||||
|
||||
const id = workspaceId.trim();
|
||||
if (!id) return;
|
||||
const workspace = workspaces().find((entry) => entry.id === id) ?? null;
|
||||
|
||||
console.log("[workspace] forget", { id });
|
||||
|
||||
try {
|
||||
const previousActive = activeWorkspaceId();
|
||||
const ws = await workspaceForget(id);
|
||||
setWorkspaces(ws.workspaces);
|
||||
const openworkWorkspace = workspace?.workspaceType === "local" ? await findOpenworkWorkspaceByPath(workspace.path) : null;
|
||||
const ws = openworkWorkspace
|
||||
? await openworkWorkspace.client.deleteWorkspace(openworkWorkspace.workspaceId).then((response) => ({
|
||||
activeId: response.activeId ?? "",
|
||||
workspaces: response.workspaces ?? response.items,
|
||||
}))
|
||||
: await workspaceForget(id);
|
||||
|
||||
if (openworkWorkspace && isTauriRuntime()) {
|
||||
try {
|
||||
await workspaceForget(id);
|
||||
} catch {
|
||||
// ignore desktop mirror failures here
|
||||
}
|
||||
}
|
||||
|
||||
if (openworkWorkspace) {
|
||||
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
|
||||
} else {
|
||||
setWorkspaces(ws.workspaces);
|
||||
}
|
||||
clearWorkspaceConnectionState(id);
|
||||
syncActiveWorkspaceId(ws.activeId);
|
||||
|
||||
if (!openworkWorkspace) {
|
||||
syncActiveWorkspaceId(ws.activeId);
|
||||
}
|
||||
|
||||
const active = ws.workspaces.find((w) => w.id === ws.activeId) ?? null;
|
||||
if (active) {
|
||||
@@ -2335,6 +2492,74 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function joinNativePath(base: string, leaf: string) {
|
||||
const trimmedBase = base.replace(/[\\/]+$/, "");
|
||||
if (!trimmedBase) return leaf;
|
||||
const separator = trimmedBase.includes("\\") ? "\\" : "/";
|
||||
return `${trimmedBase}${separator}${leaf}`;
|
||||
}
|
||||
|
||||
function deriveWorkspaceName(folderPath: string, preset: WorkspacePreset) {
|
||||
const leaf = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker";
|
||||
if (preset === "starter" && leaf.trim().toLowerCase() === STARTER_BOOTSTRAP_WORKSPACE_NAME) {
|
||||
return "Starter";
|
||||
}
|
||||
return leaf;
|
||||
}
|
||||
|
||||
async function resolveStarterBootstrapFolder() {
|
||||
const base = (await homeDir()).replace(/[\\/]+$/, "");
|
||||
return joinNativePath(joinNativePath(base, STARTER_BOOTSTRAP_FOLDER_NAME), STARTER_BOOTSTRAP_WORKSPACE_NAME);
|
||||
}
|
||||
|
||||
async function quickStartWorkspaceFlow() {
|
||||
if (!isTauriRuntime()) {
|
||||
options.setError(t("app.error.tauri_required", currentLocale()));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await createWorkspaceFlow("starter", await resolveStarterBootstrapFolder());
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoBootstrapStarterWorkspace() {
|
||||
if (!isTauriRuntime()) return false;
|
||||
|
||||
options.setStartupPreference("local");
|
||||
options.setOnboardingStep("bootstrap");
|
||||
setPersistedStarterBootstrapState("in_progress");
|
||||
options.setError(null);
|
||||
|
||||
try {
|
||||
const ok = await createWorkspaceFlow("starter", await resolveStarterBootstrapFolder());
|
||||
if (!ok) {
|
||||
setPersistedStarterBootstrapState("failed");
|
||||
options.setOnboardingStep("local");
|
||||
return false;
|
||||
}
|
||||
|
||||
setPersistedStarterBootstrapState("completed");
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
setPersistedStarterBootstrapState("failed");
|
||||
options.setOnboardingStep("local");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspaceFromPickedFolder() {
|
||||
const folder = await pickWorkspaceFolder();
|
||||
if (!folder) return false;
|
||||
return createWorkspaceFlow("minimal", folder);
|
||||
}
|
||||
|
||||
async function exportWorkspaceConfig(workspaceId?: string) {
|
||||
if (exportingWorkspaceConfig()) return;
|
||||
if (!isTauriRuntime()) {
|
||||
@@ -2661,6 +2886,32 @@ export function createWorkspaceStore(options: {
|
||||
const nextDisplayName = displayName?.trim() || null;
|
||||
options.setError(null);
|
||||
|
||||
const openworkWorkspace = workspace.workspaceType === "local"
|
||||
? await findOpenworkWorkspaceByPath(workspace.path)
|
||||
: null;
|
||||
|
||||
if (openworkWorkspace) {
|
||||
try {
|
||||
const ws = await openworkWorkspace.client.updateWorkspaceDisplayName(openworkWorkspace.workspaceId, nextDisplayName);
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName });
|
||||
} catch {
|
||||
// ignore desktop mirror failures here
|
||||
}
|
||||
}
|
||||
applyServerLocalWorkspaces(ws.workspaces, ws.activeId);
|
||||
if (ws.activeId) {
|
||||
updateWorkspaceConnectionState(ws.activeId, { status: "connected", message: null });
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const ws = await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName });
|
||||
@@ -2912,7 +3163,10 @@ export function createWorkspaceStore(options: {
|
||||
reload: existing?.reload ?? null,
|
||||
};
|
||||
|
||||
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
|
||||
const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false);
|
||||
if (!persistedViaServer) {
|
||||
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
|
||||
}
|
||||
setWorkspaceConfig(cfg);
|
||||
}
|
||||
|
||||
@@ -2933,7 +3187,10 @@ export function createWorkspaceStore(options: {
|
||||
},
|
||||
};
|
||||
|
||||
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
|
||||
const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false);
|
||||
if (!persistedViaServer) {
|
||||
await workspaceOpenworkWrite({ workspacePath: root, config: cfg });
|
||||
}
|
||||
setWorkspaceConfig(cfg);
|
||||
}
|
||||
|
||||
@@ -3000,7 +3257,9 @@ export function createWorkspaceStore(options: {
|
||||
async function bootstrapOnboarding() {
|
||||
const startupPref = readStartupPreference();
|
||||
const onboardingComplete = readInitialWorkspaceSetupComplete();
|
||||
const persistedBootstrapState = readStarterBootstrapState();
|
||||
setInitialWorkspaceSetupComplete(onboardingComplete);
|
||||
setStarterBootstrapState(persistedBootstrapState);
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
@@ -3012,6 +3271,14 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (isTauriRuntime() && persistedBootstrapState === "in_progress") {
|
||||
if (workspaces().length > 0) {
|
||||
setPersistedStarterBootstrapState("completed");
|
||||
} else {
|
||||
setPersistedStarterBootstrapState("failed");
|
||||
}
|
||||
}
|
||||
|
||||
await refreshEngine();
|
||||
await refreshEngineDoctor();
|
||||
|
||||
@@ -3100,6 +3367,11 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
|
||||
if (firstRunWorkspaceSetup()) {
|
||||
if (starterBootstrapState() === "not_started") {
|
||||
await autoBootstrapStarterWorkspace();
|
||||
return;
|
||||
}
|
||||
|
||||
options.setStartupPreference("local");
|
||||
options.setOnboardingStep("local");
|
||||
return;
|
||||
@@ -3230,6 +3502,8 @@ export function createWorkspaceStore(options: {
|
||||
testWorkspaceConnection,
|
||||
connectToServer,
|
||||
createWorkspaceFlow,
|
||||
quickStartWorkspaceFlow,
|
||||
createWorkspaceFromPickedFolder,
|
||||
createSandboxFlow,
|
||||
createRemoteWorkspaceFlow,
|
||||
updateRemoteWorkspaceFlow,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
import type { ScheduledJob } from "./tauri";
|
||||
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
|
||||
|
||||
export type OpenworkServerCapabilities = {
|
||||
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
|
||||
@@ -88,13 +88,7 @@ export type OpenworkServerSettings = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type OpenworkWorkspaceInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
workspaceType: "local" | "remote";
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
export type OpenworkWorkspaceInfo = WorkspaceInfo & {
|
||||
opencode?: {
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
@@ -105,6 +99,7 @@ export type OpenworkWorkspaceInfo = {
|
||||
|
||||
export type OpenworkWorkspaceList = {
|
||||
items: OpenworkWorkspaceInfo[];
|
||||
workspaces?: WorkspaceInfo[];
|
||||
activeId?: string | null;
|
||||
};
|
||||
|
||||
@@ -1216,6 +1211,22 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
opencodeRouterSlackIdentities: () =>
|
||||
requestJsonRaw<OpenworkOpenCodeRouterSlackIdentitiesResult>(baseUrl, "/opencode-router/identities/slack", { token, hostToken, timeoutMs: timeouts.opencodeRouter }),
|
||||
listWorkspaces: () => requestJson<OpenworkWorkspaceList>(baseUrl, "/workspaces", { token, hostToken, timeoutMs: timeouts.listWorkspaces }),
|
||||
createLocalWorkspace: (payload: { folderPath: string; name: string; preset: string }) =>
|
||||
requestJson<WorkspaceList>(baseUrl, "/workspaces/local", {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: payload,
|
||||
timeoutMs: timeouts.activateWorkspace,
|
||||
}),
|
||||
updateWorkspaceDisplayName: (workspaceId: string, displayName: string | null) =>
|
||||
requestJson<WorkspaceList>(baseUrl, `/workspaces/${encodeURIComponent(workspaceId)}/display-name`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "PATCH",
|
||||
body: { displayName },
|
||||
timeoutMs: timeouts.activateWorkspace,
|
||||
}),
|
||||
activateWorkspace: (workspaceId: string) =>
|
||||
requestJson<{ activeId: string; workspace: OpenworkWorkspaceInfo }>(
|
||||
baseUrl,
|
||||
@@ -1223,7 +1234,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
{ token, hostToken, method: "POST", timeoutMs: timeouts.activateWorkspace },
|
||||
),
|
||||
deleteWorkspace: (workspaceId: string) =>
|
||||
requestJson<{ ok: boolean; deleted: boolean; persisted: boolean; activeId: string | null; items: OpenworkWorkspaceInfo[] }>(
|
||||
requestJson<{ ok: boolean; deleted: boolean; persisted: boolean; activeId: string | null; items: OpenworkWorkspaceInfo[]; workspaces?: WorkspaceInfo[] }>(
|
||||
baseUrl,
|
||||
`/workspaces/${encodeURIComponent(workspaceId)}`,
|
||||
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteWorkspace },
|
||||
@@ -1474,6 +1485,20 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
method: "PATCH",
|
||||
body: payload,
|
||||
}),
|
||||
readOpencodeConfigFile: (workspaceId: string, scope: "project" | "global" = "project") => {
|
||||
const query = `?scope=${scope}`;
|
||||
return requestJson<OpencodeConfigFile>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config${query}`, {
|
||||
token,
|
||||
hostToken,
|
||||
});
|
||||
},
|
||||
writeOpencodeConfigFile: (workspaceId: string, scope: "project" | "global", content: string) =>
|
||||
requestJson<ExecResult>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/opencode-config`, {
|
||||
token,
|
||||
hostToken,
|
||||
method: "POST",
|
||||
body: { scope, content },
|
||||
}),
|
||||
listReloadEvents: (workspaceId: string, options?: { since?: number }) => {
|
||||
const query = typeof options?.since === "number" ? `?since=${options.since}` : "";
|
||||
return requestJson<{ items: OpenworkReloadEvent[]; cursor?: number }>(
|
||||
|
||||
@@ -1628,6 +1628,7 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
onOpenAutomations={() => props.setTab("scheduled")}
|
||||
onOpenSkills={() => props.setTab("skills")}
|
||||
onOpenExtensions={() => props.setTab("mcp")}
|
||||
onOpenMessaging={() => props.setTab("identities")}
|
||||
onOpenAdvanced={openConfig}
|
||||
onOpenSettings={() => openSettings("general")}
|
||||
/>
|
||||
@@ -1652,6 +1653,7 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
onOpenAutomations={() => props.setTab("scheduled")}
|
||||
onOpenSkills={() => props.setTab("skills")}
|
||||
onOpenExtensions={() => props.setTab("mcp")}
|
||||
onOpenMessaging={() => props.setTab("identities")}
|
||||
onOpenAdvanced={openConfig}
|
||||
onOpenSettings={() => openSettings("general")}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ export type McpViewProps = {
|
||||
busy: boolean;
|
||||
activeWorkspaceRoot: string;
|
||||
isRemoteWorkspace: boolean;
|
||||
readConfigFile?: (scope: "project" | "global") => Promise<OpencodeConfigFile | null>;
|
||||
showHeader?: boolean;
|
||||
mcpServers: McpServerEntry[];
|
||||
mcpStatus: string | null;
|
||||
@@ -155,8 +156,9 @@ export default function McpView(props: McpViewProps) {
|
||||
createEffect(() => {
|
||||
const root = props.activeWorkspaceRoot.trim();
|
||||
const nextId = (configRequestId += 1);
|
||||
const readConfig = props.readConfigFile;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
if (!readConfig && !isTauriRuntime()) {
|
||||
setProjectConfig(null);
|
||||
setGlobalConfig(null);
|
||||
setConfigError(null);
|
||||
@@ -167,8 +169,10 @@ export default function McpView(props: McpViewProps) {
|
||||
try {
|
||||
setConfigError(null);
|
||||
const [project, global] = await Promise.all([
|
||||
root ? readOpencodeConfig("project", root) : Promise.resolve(null),
|
||||
readOpencodeConfig("global", root),
|
||||
root
|
||||
? (readConfig ? readConfig("project") : readOpencodeConfig("project", root))
|
||||
: Promise.resolve(null),
|
||||
readConfig ? readConfig("global") : readOpencodeConfig("global", root),
|
||||
]);
|
||||
if (nextId !== configRequestId) return;
|
||||
setProjectConfig(project);
|
||||
@@ -207,7 +211,12 @@ export default function McpView(props: McpViewProps) {
|
||||
setRevealBusy(true);
|
||||
setConfigError(null);
|
||||
try {
|
||||
const resolved = await readOpencodeConfig(configScope(), root);
|
||||
const resolved = props.readConfigFile
|
||||
? await props.readConfigFile(configScope())
|
||||
: await readOpencodeConfig(configScope(), root);
|
||||
if (!resolved) {
|
||||
throw new Error(tr("mcp.config_load_failed"));
|
||||
}
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(resolved.path);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Match, Show, Switch, createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import type { OnboardingStep, StartupPreference } from "../types";
|
||||
import type { WorkspaceInfo } from "../lib/tauri";
|
||||
import { CheckCircle2, ChevronDown, Circle, Globe } from "lucide-solid";
|
||||
import { CheckCircle2, ChevronDown, Circle, Globe, HardDrive } from "lucide-solid";
|
||||
|
||||
import Button from "../components/button";
|
||||
import OnboardingWorkspaceSelector from "../components/onboarding-workspace-selector";
|
||||
@@ -63,6 +63,7 @@ export type OnboardingViewProps = {
|
||||
onInstallEngine: () => void;
|
||||
onShowSearchNotes: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAdvancedSettings: () => void;
|
||||
themeMode: "light" | "dark" | "system";
|
||||
setThemeMode: (value: "light" | "dark" | "system") => void;
|
||||
};
|
||||
@@ -75,7 +76,7 @@ export default function OnboardingView(props: OnboardingViewProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (props.onboardingStep !== "connecting") {
|
||||
if (props.onboardingStep !== "connecting" && props.onboardingStep !== "bootstrap") {
|
||||
setConnectingFallbackVisible(false);
|
||||
return;
|
||||
}
|
||||
@@ -109,43 +110,62 @@ export default function OnboardingView(props: OnboardingViewProps) {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.onboardingStep === "connecting"}>
|
||||
<Match when={props.onboardingStep === "connecting" || props.onboardingStep === "bootstrap"}>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-1 text-gray-12 p-6 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-gray-2 via-gray-1 to-gray-1 opacity-50" />
|
||||
<div class="z-10 flex flex-col items-center gap-6">
|
||||
<div class="relative">
|
||||
<OpenWorkLogo size={40} />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-medium mb-2">
|
||||
{props.startupPreference === "local"
|
||||
? translate("onboarding.starting_host")
|
||||
: translate("onboarding.searching_host")}
|
||||
</h2>
|
||||
<p class="text-gray-10 text-sm">
|
||||
{props.startupPreference === "local"
|
||||
? translate("onboarding.getting_ready")
|
||||
: translate("onboarding.verifying")}
|
||||
</p>
|
||||
<div class="z-10 w-full max-w-lg rounded-[28px] border border-dls-border bg-dls-surface/95 p-8 shadow-[var(--dls-shell-shadow)]">
|
||||
<div class="flex flex-col items-center gap-6 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-[22px] border border-dls-border bg-dls-sidebar shadow-[var(--dls-card-shadow)]">
|
||||
<Show when={props.onboardingStep === "bootstrap"} fallback={<OpenWorkLogo size={36} />}>
|
||||
<HardDrive size={24} class="text-gray-11" />
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap" ? "First-time setup" : "Connecting"}
|
||||
</div>
|
||||
<h2 class="mt-3 text-2xl font-semibold tracking-tight text-gray-12">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "Getting your starter workspace ready"
|
||||
: props.startupPreference === "local"
|
||||
? translate("onboarding.starting_host")
|
||||
: translate("onboarding.searching_host")}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "We’re preparing everything so you can start in one click. This only happens the first time."
|
||||
: props.startupPreference === "local"
|
||||
? translate("onboarding.getting_ready")
|
||||
: translate("onboarding.verifying")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full rounded-[22px] border border-dls-border bg-dls-sidebar px-5 py-4 text-left">
|
||||
<div class="flex items-center gap-3 text-sm font-medium text-gray-12">
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-dls-accent animate-pulse" />
|
||||
{props.onboardingStep === "bootstrap" ? "Preparing your workspace" : "Waiting for your host"}
|
||||
</div>
|
||||
<div class="mt-2 text-xs leading-6 text-dls-secondary">
|
||||
{props.onboardingStep === "bootstrap"
|
||||
? "Starting local services, setting up your starter folder, and opening your first session."
|
||||
: "Checking your local OpenWork services and reconnecting to the selected workspace."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.error}>
|
||||
<div class="mt-4 rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 text-left">
|
||||
<div class="w-full rounded-2xl bg-red-1/40 px-5 py-4 text-sm text-red-12 border border-red-7/20 text-left">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={connectingFallbackVisible()}>
|
||||
<div class="mt-5 flex items-center justify-center gap-2">
|
||||
<Button variant="secondary" onClick={props.onOpenSettings} disabled={props.busy}>
|
||||
{translate("onboarding.open_settings")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={props.onBackToWelcome} disabled={props.busy}>
|
||||
{translate("onboarding.back")}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button variant="secondary" onClick={props.onOpenAdvancedSettings} disabled={props.busy}>
|
||||
Having trouble?
|
||||
</Button>
|
||||
<div class="text-xs text-gray-10">Open Advanced settings to check local host configuration.</div>
|
||||
</div>
|
||||
<div class="mt-3 text-xs text-gray-10">{translate("onboarding.open_settings_hint")}</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Circle,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
@@ -132,6 +133,8 @@ export type SessionViewProps = {
|
||||
editWorkspaceConnection: (workspaceId: string) => void;
|
||||
forgetWorkspace: (workspaceId: string) => void;
|
||||
openCreateWorkspace: () => void;
|
||||
getStartedWorkspace: () => Promise<boolean>;
|
||||
pickFolderWorkspace: () => Promise<boolean>;
|
||||
openCreateRemoteWorkspace: () => void;
|
||||
importWorkspaceConfig: () => void;
|
||||
importingWorkspaceConfig: boolean;
|
||||
@@ -4209,32 +4212,62 @@ export default function SessionView(props: SessionViewProps) {
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[800px]">
|
||||
<Show when={showWorkspaceSetupEmptyState()}>
|
||||
<div class="mx-auto max-w-xl rounded-[24px] border border-dls-border bg-dls-sidebar p-8 text-center shadow-[var(--dls-card-shadow)]">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface text-gray-11">
|
||||
<HardDrive size={24} />
|
||||
</div>
|
||||
<h3 class="text-2xl font-semibold text-gray-12">
|
||||
Set up your first workspace
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-10">
|
||||
OpenWork needs a local or remote workspace before you can
|
||||
start a session.
|
||||
</p>
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-gray-7 bg-gray-12 px-5 py-3 text-sm font-semibold text-gray-1 transition-colors hover:bg-gray-11"
|
||||
onClick={props.openCreateWorkspace}
|
||||
>
|
||||
Create local workspace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-dls-border bg-dls-surface px-5 py-3 text-sm font-semibold text-gray-12 transition-colors hover:bg-gray-2"
|
||||
onClick={props.openCreateRemoteWorkspace}
|
||||
>
|
||||
Connect remote workspace
|
||||
</button>
|
||||
<div class="mx-auto max-w-2xl rounded-[32px] border border-dls-border bg-dls-sidebar/95 p-5 shadow-[var(--dls-shell-shadow)] sm:p-8">
|
||||
<div class="rounded-[28px] border border-dls-border bg-dls-surface p-6 sm:p-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-dls-border bg-dls-sidebar text-gray-11 shadow-[var(--dls-card-shadow)]">
|
||||
<HardDrive size={24} />
|
||||
</div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-dls-secondary">
|
||||
Workspace setup
|
||||
</div>
|
||||
<h3 class="mt-3 text-3xl font-semibold tracking-tight text-gray-12">
|
||||
Set up your first workspace
|
||||
</h3>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-dls-secondary sm:text-[15px]">
|
||||
Start with a guided OpenWork workspace, or choose an existing folder you want to work in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-[1.2fr_1fr]">
|
||||
<button
|
||||
type="button"
|
||||
class="group rounded-[24px] border border-transparent bg-dls-accent px-5 py-5 text-left text-white shadow-[var(--dls-card-shadow)] transition-all hover:-translate-y-0.5 hover:bg-[var(--dls-accent-hover)]"
|
||||
onClick={() => void props.getStartedWorkspace()}
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/20 bg-white/10">
|
||||
<HardDrive size={18} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold">Get Started</div>
|
||||
<div class="mt-1 text-sm leading-6 text-white/80">
|
||||
Start working right away in a new OpenWork folder.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group rounded-[24px] border border-dls-border bg-dls-sidebar px-5 py-5 text-left text-gray-12 transition-all hover:-translate-y-0.5 hover:border-gray-7 hover:bg-gray-2/80"
|
||||
onClick={() => void props.pickFolderWorkspace()}
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-dls-border bg-dls-surface text-gray-11">
|
||||
<FolderOpen size={18} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold">Pick a folder you want to work in</div>
|
||||
<div class="mt-1 text-sm leading-6 text-dls-secondary">
|
||||
Choose an existing project or notes folder and OpenWork will use it as your workspace.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -4581,6 +4614,10 @@ export default function SessionView(props: SessionViewProps) {
|
||||
props.setTab("mcp");
|
||||
props.setView("dashboard");
|
||||
}}
|
||||
onOpenMessaging={() => {
|
||||
props.setTab("identities");
|
||||
props.setView("dashboard");
|
||||
}}
|
||||
onOpenAdvanced={openConfig}
|
||||
onOpenSettings={() => openSettings("general")}
|
||||
onInboxToast={(message) => setToastMessage(message)}
|
||||
@@ -4616,6 +4653,10 @@ export default function SessionView(props: SessionViewProps) {
|
||||
props.setTab("mcp");
|
||||
props.setView("dashboard");
|
||||
}}
|
||||
onOpenMessaging={() => {
|
||||
props.setTab("identities");
|
||||
props.setView("dashboard");
|
||||
}}
|
||||
onOpenAdvanced={openConfig}
|
||||
onOpenSettings={() => openSettings("general")}
|
||||
onInboxToast={(message) => setToastMessage(message)}
|
||||
|
||||
@@ -144,7 +144,7 @@ export type StartupPreference = "local" | "server";
|
||||
|
||||
export type EngineRuntime = "direct" | "openwork-orchestrator";
|
||||
|
||||
export type OnboardingStep = "welcome" | "local" | "server" | "connecting";
|
||||
export type OnboardingStep = "welcome" | "local" | "server" | "connecting" | "bootstrap";
|
||||
|
||||
export type DashboardTab =
|
||||
| "scheduled"
|
||||
|
||||
@@ -86,6 +86,20 @@ export function isWindowsPlatform() {
|
||||
return /windows/i.test(platform) || /windows/i.test(ua);
|
||||
}
|
||||
|
||||
export function isMacPlatform() {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
|
||||
const ua = typeof navigator.userAgent === "string" ? navigator.userAgent : "";
|
||||
const platform =
|
||||
typeof (navigator as any).userAgentData?.platform === "string"
|
||||
? (navigator as any).userAgentData.platform
|
||||
: typeof navigator.platform === "string"
|
||||
? navigator.platform
|
||||
: "";
|
||||
|
||||
return /mac/i.test(platform) || /macintosh|mac os x/i.test(ua);
|
||||
}
|
||||
|
||||
const STARTUP_PREF_KEY = "openwork.startupPref";
|
||||
const LEGACY_PREF_KEY = "openwork.modePref";
|
||||
const LEGACY_PREF_KEY_ALT = "openwork_mode_pref";
|
||||
@@ -191,7 +205,7 @@ export function normalizeDirectoryQueryPath(input?: string | null) {
|
||||
export function normalizeDirectoryPath(input?: string | null) {
|
||||
const normalized = normalizeDirectoryQueryPath(input);
|
||||
if (!normalized) return "";
|
||||
return isWindowsPlatform() ? normalized.toLowerCase() : normalized;
|
||||
return isWindowsPlatform() || isMacPlatform() ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function normalizeEvent(raw: unknown): OpencodeEvent | null {
|
||||
|
||||
1
apps/desktop/src-tauri/Cargo.lock
generated
1
apps/desktop/src-tauri/Cargo.lock
generated
@@ -2805,6 +2805,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
|
||||
@@ -21,8 +21,9 @@ json5 = "0.4"
|
||||
notify = "6.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = "2"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
sha2 = "0.10"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-http = "2.5.6"
|
||||
tauri-plugin-opener = "2.5.3"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::types::{WorkspaceState, WORKSPACE_STATE_VERSION};
|
||||
use crate::types::{WorkspaceState, WorkspaceType, WORKSPACE_STATE_VERSION};
|
||||
|
||||
pub fn stable_workspace_id(path: &str) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
format!("ws-{:x}", hasher.finish())
|
||||
let digest = Sha256::digest(path.as_bytes());
|
||||
let hex = format!("{:x}", digest);
|
||||
format!("ws_{}", &hex[..12])
|
||||
}
|
||||
|
||||
pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
|
||||
@@ -32,10 +32,47 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, St
|
||||
let mut state: WorkspaceState = serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
|
||||
|
||||
let mut changed_ids = false;
|
||||
let old_active_id = state.active_id.clone();
|
||||
for workspace in state.workspaces.iter_mut() {
|
||||
let next_id = match workspace.workspace_type {
|
||||
WorkspaceType::Local => stable_workspace_id(&workspace.path),
|
||||
WorkspaceType::Remote => {
|
||||
if workspace.remote_type == Some(crate::types::RemoteType::Openwork) {
|
||||
stable_workspace_id_for_openwork(
|
||||
workspace.openwork_host_url.as_deref().unwrap_or(""),
|
||||
workspace.openwork_workspace_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
stable_workspace_id_for_remote(
|
||||
workspace.base_url.as_deref().unwrap_or(""),
|
||||
workspace.directory.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if workspace.id != next_id {
|
||||
if old_active_id == workspace.id {
|
||||
state.active_id = next_id.clone();
|
||||
}
|
||||
workspace.id = next_id;
|
||||
changed_ids = true;
|
||||
}
|
||||
}
|
||||
|
||||
if state.version < WORKSPACE_STATE_VERSION {
|
||||
state.version = WORKSPACE_STATE_VERSION;
|
||||
}
|
||||
|
||||
if changed_ids && state.active_id.is_empty() {
|
||||
state.active_id = state
|
||||
.workspaces
|
||||
.first()
|
||||
.map(|workspace| workspace.id.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
|
||||
40
apps/server/src/commands.test.ts
Normal file
40
apps/server/src/commands.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { listCommands, upsertCommand } from "./commands.js";
|
||||
|
||||
describe("commands", () => {
|
||||
test("upsertCommand omits null model from frontmatter", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "openwork-commands-"));
|
||||
|
||||
const path = await upsertCommand(workspace, {
|
||||
name: "learn-files",
|
||||
description: "Learn files",
|
||||
template: "Show me the files",
|
||||
model: null,
|
||||
});
|
||||
|
||||
const content = await readFile(path, "utf8");
|
||||
expect(content).not.toContain("model: null");
|
||||
expect(content).not.toContain("model:");
|
||||
});
|
||||
|
||||
test("listCommands repairs legacy null model frontmatter", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "openwork-commands-"));
|
||||
const commandsDir = join(workspace, ".opencode", "commands");
|
||||
const commandPath = join(commandsDir, "learn-files.md");
|
||||
|
||||
await mkdir(commandsDir, { recursive: true });
|
||||
await writeFile(commandPath, "---\nname: learn-files\ndescription: Learn files\nmodel: null\n---\nShow me the files\n", "utf8");
|
||||
|
||||
const commands = await listCommands(workspace, "workspace");
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]?.model).toBeNull();
|
||||
|
||||
const repaired = await readFile(commandPath, "utf8");
|
||||
expect(repaired).not.toContain("model: null");
|
||||
expect(repaired).not.toContain("model:");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,26 @@ import { projectCommandsDir } from "./workspace-files.js";
|
||||
import { validateCommandName, sanitizeCommandName } from "./validators.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
function normalizeCommandFrontmatter(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([, value]) => value !== null && value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
async function repairLegacyCommandFile(filePath: string, content: string): Promise<{ data: Record<string, unknown>; body: string }> {
|
||||
const parsed = parseFrontmatter(content);
|
||||
if (parsed.data.model !== null) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const nextContent = buildFrontmatter(normalizeCommandFrontmatter(parsed.data)) + parsed.body.replace(/^\n?/, "\n");
|
||||
await writeFile(filePath, nextContent, "utf8");
|
||||
return {
|
||||
data: normalizeCommandFrontmatter(parsed.data),
|
||||
body: parsed.body,
|
||||
};
|
||||
}
|
||||
|
||||
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
@@ -17,7 +37,7 @@ async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Pr
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
const filePath = join(dir, entry.name);
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const { data, body } = parseFrontmatter(content);
|
||||
const { data, body } = await repairLegacyCommandFile(filePath, content);
|
||||
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
|
||||
try {
|
||||
validateCommandName(name);
|
||||
@@ -54,13 +74,13 @@ export async function upsertCommand(
|
||||
}
|
||||
const sanitized = sanitizeCommandName(payload.name);
|
||||
validateCommandName(sanitized);
|
||||
const frontmatter = buildFrontmatter({
|
||||
const frontmatter = buildFrontmatter(normalizeCommandFrontmatter({
|
||||
name: sanitized,
|
||||
description: payload.description,
|
||||
agent: payload.agent,
|
||||
model: payload.model ?? null,
|
||||
model: payload.model,
|
||||
subtask: payload.subtask ?? false,
|
||||
});
|
||||
}));
|
||||
const content = frontmatter + "\n" + payload.template.trim() + "\n";
|
||||
const dir = projectCommandsDir(workspaceRoot);
|
||||
await mkdir(dir, { recursive: true });
|
||||
@@ -69,6 +89,10 @@ export async function upsertCommand(
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function repairCommands(workspaceRoot: string): Promise<void> {
|
||||
await listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
|
||||
}
|
||||
|
||||
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
|
||||
const sanitized = sanitizeCommandName(name);
|
||||
validateCommandName(sanitized);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile, rm, readdir, rename, stat } from "node:fs/promises";
|
||||
import { createHash, randomInt } from "node:crypto";
|
||||
import { homedir, hostname } from "node:os";
|
||||
@@ -8,16 +9,18 @@ import { addPlugin, listPlugins, normalizePluginSpec, removePlugin } from "./plu
|
||||
import { addMcp, listMcp, removeMcp } from "./mcp.js";
|
||||
import { deleteSkill, listSkills, upsertSkill } from "./skills.js";
|
||||
import { installHubSkill, listHubSkills } from "./skill-hub.js";
|
||||
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
|
||||
import { deleteCommand, listCommands, repairCommands, upsertCommand } from "./commands.js";
|
||||
import { deleteScheduledJob, listScheduledJobs, resolveScheduledJob } from "./scheduler.js";
|
||||
import { ApiError, formatError } from "./errors.js";
|
||||
import { readJsoncFile, updateJsoncPath, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
|
||||
import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js";
|
||||
import { ReloadEventStore } from "./events.js";
|
||||
import { startReloadWatchers } from "./reload-watcher.js";
|
||||
import { parseFrontmatter } from "./frontmatter.js";
|
||||
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
|
||||
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
|
||||
import { workspaceIdForPath } from "./workspaces.js";
|
||||
import { ensureWorkspaceFiles, readRawOpencodeConfig } from "./workspace-init.js";
|
||||
import { sanitizeCommandName, validateMcpName } from "./validators.js";
|
||||
import { TokenService } from "./tokens.js";
|
||||
import { TOY_UI_CSS, TOY_UI_FAVICON_SVG, TOY_UI_HTML, TOY_UI_JS, cssResponse, htmlResponse, jsResponse, svgResponse } from "./toy-ui.js";
|
||||
@@ -238,8 +241,13 @@ export function startServer(config: ServerConfig) {
|
||||
const approvals = new ApprovalService(config.approval);
|
||||
const reloadEvents = new ReloadEventStore();
|
||||
const tokens = new TokenService(config);
|
||||
const routes = createRoutes(config, approvals, tokens);
|
||||
const logger = createServerLogger(config);
|
||||
let watcherHandle = startReloadWatchers({ config, reloadEvents, logger });
|
||||
const restartReloadWatchers = () => {
|
||||
watcherHandle.close();
|
||||
watcherHandle = startReloadWatchers({ config, reloadEvents, logger });
|
||||
};
|
||||
const routes = createRoutes(config, approvals, tokens, restartReloadWatchers);
|
||||
|
||||
const serverOptions: {
|
||||
hostname: string;
|
||||
@@ -1184,10 +1192,7 @@ function emitReloadEvent(
|
||||
reason: ReloadReason,
|
||||
trigger?: ReloadTrigger,
|
||||
) {
|
||||
void reloadEvents;
|
||||
void workspace;
|
||||
void reason;
|
||||
void trigger;
|
||||
reloadEvents.recordDebounced(workspace.id, reason, trigger);
|
||||
}
|
||||
|
||||
function buildConfigTrigger(path: string): ReloadTrigger {
|
||||
@@ -1218,7 +1223,12 @@ function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) {
|
||||
};
|
||||
}
|
||||
|
||||
function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens: TokenService): Route[] {
|
||||
function createRoutes(
|
||||
config: ServerConfig,
|
||||
approvals: ApprovalService,
|
||||
tokens: TokenService,
|
||||
onWorkspacesChanged: () => void,
|
||||
): Route[] {
|
||||
const routes: Route[] = [];
|
||||
const fileSessions = new FileSessionStore();
|
||||
|
||||
@@ -1394,7 +1404,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
addRoute(routes, "GET", "/workspaces", "client", async () => {
|
||||
const active = config.workspaces[0] ?? null;
|
||||
const items = config.workspaces.map(serializeWorkspace);
|
||||
return jsonResponse({ items, activeId: active?.id ?? null });
|
||||
return jsonResponse({ items, workspaces: items, activeId: active?.id ?? null });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/tokens", "host", async () => {
|
||||
@@ -1424,6 +1434,91 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspaces/local", "host", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const folderPath = typeof body.folderPath === "string" ? body.folderPath.trim() : "";
|
||||
const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : basename(folderPath || "Workspace");
|
||||
const preset = typeof body.preset === "string" && body.preset.trim() ? body.preset.trim() : "starter";
|
||||
|
||||
if (!folderPath) {
|
||||
throw new ApiError(400, "invalid_payload", "folderPath is required");
|
||||
}
|
||||
|
||||
const workspacePath = resolve(folderPath);
|
||||
await ensureDir(workspacePath);
|
||||
await ensureWorkspaceFiles(workspacePath, preset);
|
||||
|
||||
const workspace: WorkspaceInfo = {
|
||||
id: workspaceIdForPath(workspacePath),
|
||||
name,
|
||||
path: workspacePath,
|
||||
preset,
|
||||
workspaceType: "local",
|
||||
};
|
||||
|
||||
config.workspaces = [workspace, ...config.workspaces.filter((entry) => entry.id !== workspace.id)];
|
||||
if (!config.authorizedRoots.some((root) => resolve(root) === workspacePath)) {
|
||||
config.authorizedRoots = [...config.authorizedRoots, workspacePath];
|
||||
}
|
||||
const persisted = await persistServerWorkspaceState(config);
|
||||
onWorkspacesChanged();
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "host" },
|
||||
action: "workspace.create",
|
||||
target: workspace.path,
|
||||
summary: `Created workspace ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
activeId: workspace.id,
|
||||
workspaces: config.workspaces.map(serializeWorkspace),
|
||||
persisted,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
addRoute(routes, "PATCH", "/workspaces/:id/display-name", "host", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const nextDisplayName = typeof body.displayName === "string" && body.displayName.trim()
|
||||
? body.displayName.trim()
|
||||
: undefined;
|
||||
|
||||
config.workspaces = config.workspaces.map((entry) =>
|
||||
entry.id === workspace.id
|
||||
? {
|
||||
...entry,
|
||||
displayName: nextDisplayName,
|
||||
name: nextDisplayName ?? entry.name,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
|
||||
const persisted = await persistServerWorkspaceState(config);
|
||||
onWorkspacesChanged();
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "host" },
|
||||
action: "workspace.rename",
|
||||
target: workspace.path,
|
||||
summary: `Updated workspace display name${nextDisplayName ? ` to ${nextDisplayName}` : ""}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
activeId: config.workspaces[0]?.id ?? null,
|
||||
workspaces: config.workspaces.map(serializeWorkspace),
|
||||
persisted,
|
||||
});
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspaces/:id/activate", "host", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
config.workspaces = [
|
||||
@@ -1439,7 +1534,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
summary: "Switched active workspace",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ activeId: workspace.id, workspace: serializeWorkspace(workspace) });
|
||||
return jsonResponse({ activeId: workspace.id, workspace: serializeWorkspace(workspace), persisted: false });
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspaces/:id", "host", async (ctx) => {
|
||||
@@ -1447,12 +1542,6 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
|
||||
// Attempt to persist to server.json (when present) before mutating in-memory state.
|
||||
const configPath = config.configPath?.trim() ?? "";
|
||||
const persisted = configPath
|
||||
? await persistWorkspaceDeletion(configPath, workspace.id, workspace.path)
|
||||
: false;
|
||||
|
||||
const before = config.workspaces.length;
|
||||
config.workspaces = config.workspaces.filter((entry) => entry.id !== workspace.id);
|
||||
const deleted = before !== config.workspaces.length;
|
||||
@@ -1461,6 +1550,8 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
// Only remove exact matches; authorizedRoots can contain broader entries.
|
||||
config.authorizedRoots = config.authorizedRoots.filter((root) => resolve(root) !== resolve(workspace.path));
|
||||
}
|
||||
const persisted = await persistServerWorkspaceState(config);
|
||||
onWorkspacesChanged();
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
@@ -1479,6 +1570,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
persisted,
|
||||
activeId: active?.id ?? null,
|
||||
items: config.workspaces.map(serializeWorkspace),
|
||||
workspaces: config.workspaces.map(serializeWorkspace),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1490,6 +1582,58 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/opencode-config", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const scope = normalizeOpencodeScope(ctx.url.searchParams.get("scope"));
|
||||
const configPath = resolveOpencodeConfigFilePath(scope, workspace.path);
|
||||
const result = await readRawOpencodeConfig(configPath);
|
||||
return jsonResponse({ path: configPath, exists: result.exists, content: result.content });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/opencode-config", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
requireClientScope(ctx, "collaborator");
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const scope = normalizeOpencodeScope(typeof body.scope === "string" ? body.scope : null);
|
||||
const content = typeof body.content === "string" ? body.content : null;
|
||||
if (content === null) {
|
||||
throw new ApiError(400, "invalid_payload", "content must be a string");
|
||||
}
|
||||
|
||||
const configPath = resolveOpencodeConfigFilePath(scope, workspace.path);
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: scope === "global" ? "config.global.write" : "config.write",
|
||||
summary: `Write ${scope} OpenCode config`,
|
||||
paths: [configPath],
|
||||
});
|
||||
|
||||
await ensureDir(dirname(configPath));
|
||||
await writeFile(configPath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: scope === "global" ? "config.global.write" : "config.write",
|
||||
target: configPath,
|
||||
summary: `Updated ${scope} OpenCode config`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (scope === "project") {
|
||||
emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(configPath));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
status: 0,
|
||||
stdout: `Wrote ${configPath}`,
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/audit", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const limitParam = ctx.url.searchParams.get("limit");
|
||||
@@ -2492,8 +2636,10 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService, tokens:
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/events", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
void ctx;
|
||||
return jsonResponse({ items: [], cursor: 0, workspaceId: workspace.id, disabled: true });
|
||||
const sinceRaw = ctx.url.searchParams.get("since");
|
||||
const since = sinceRaw ? Number(sinceRaw) : undefined;
|
||||
const items = ctx.reloadEvents.list(workspace.id, since);
|
||||
return jsonResponse({ items, cursor: ctx.reloadEvents.cursor(), workspaceId: workspace.id, disabled: false });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/engine/reload", "client", async (ctx) => {
|
||||
@@ -3803,6 +3949,9 @@ async function resolveWorkspace(config: ServerConfig, id: string): Promise<Works
|
||||
if (!authorized) {
|
||||
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
|
||||
}
|
||||
if (!config.readOnly) {
|
||||
await repairCommands(resolvedWorkspace);
|
||||
}
|
||||
return { ...workspace, path: resolvedWorkspace };
|
||||
}
|
||||
|
||||
@@ -3912,62 +4061,55 @@ type OpenworkServerConfigFile = Record<string, unknown> & {
|
||||
authorizedRoots?: string[];
|
||||
};
|
||||
|
||||
async function persistWorkspaceDeletion(configPath: string, workspaceId: string, workspacePath: string): Promise<boolean> {
|
||||
if (!configPath.trim()) return false;
|
||||
async function readServerConfigFile(configPath: string): Promise<OpenworkServerConfigFile> {
|
||||
if (!(await exists(configPath))) {
|
||||
// If the server was started from CLI args/env, avoid implicitly creating server.json
|
||||
// because it can change token behavior on restart.
|
||||
return false;
|
||||
return {};
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = await readFile(configPath, "utf8");
|
||||
} catch (error) {
|
||||
throw new ApiError(500, "server_config_read_failed", "Failed to read server config", {
|
||||
path: configPath,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: OpenworkServerConfigFile;
|
||||
try {
|
||||
parsed = ensurePlainObject(JSON.parse(raw)) as OpenworkServerConfigFile;
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
return ensurePlainObject(JSON.parse(raw)) as OpenworkServerConfigFile;
|
||||
} catch (error) {
|
||||
throw new ApiError(422, "invalid_json", "Failed to parse server config", {
|
||||
path: configPath,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configDir = dirname(configPath);
|
||||
const workspacesRaw = parsed.workspaces;
|
||||
const workspaces = Array.isArray(workspacesRaw) ? workspacesRaw : [];
|
||||
function serializeWorkspaceConfigEntry(workspace: WorkspaceInfo): Record<string, unknown> {
|
||||
return {
|
||||
id: workspace.id,
|
||||
path: workspace.path,
|
||||
name: workspace.name,
|
||||
preset: workspace.preset,
|
||||
workspaceType: workspace.workspaceType,
|
||||
...(workspace.remoteType ? { remoteType: workspace.remoteType } : {}),
|
||||
...(workspace.baseUrl ? { baseUrl: workspace.baseUrl } : {}),
|
||||
...(workspace.directory ? { directory: workspace.directory } : {}),
|
||||
...(workspace.displayName ? { displayName: workspace.displayName } : {}),
|
||||
...(workspace.openworkHostUrl ? { openworkHostUrl: workspace.openworkHostUrl } : {}),
|
||||
...(workspace.openworkToken ? { openworkToken: workspace.openworkToken } : {}),
|
||||
...(workspace.openworkWorkspaceId ? { openworkWorkspaceId: workspace.openworkWorkspaceId } : {}),
|
||||
...(workspace.openworkWorkspaceName ? { openworkWorkspaceName: workspace.openworkWorkspaceName } : {}),
|
||||
...(workspace.sandboxBackend ? { sandboxBackend: workspace.sandboxBackend } : {}),
|
||||
...(workspace.sandboxRunId ? { sandboxRunId: workspace.sandboxRunId } : {}),
|
||||
...(workspace.sandboxContainerName ? { sandboxContainerName: workspace.sandboxContainerName } : {}),
|
||||
...(workspace.opencodeUsername ? { opencodeUsername: workspace.opencodeUsername } : {}),
|
||||
...(workspace.opencodePassword ? { opencodePassword: workspace.opencodePassword } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const nextWorkspaces = workspaces.filter((entry) => {
|
||||
const obj = ensurePlainObject(entry);
|
||||
const path = typeof obj.path === "string" ? obj.path.trim() : "";
|
||||
if (!path) return true;
|
||||
const id = workspaceIdForPath(resolve(configDir, path));
|
||||
return id !== workspaceId;
|
||||
});
|
||||
|
||||
const rootsRaw = parsed.authorizedRoots;
|
||||
const roots = Array.isArray(rootsRaw) ? rootsRaw : [];
|
||||
const nextRoots = roots.filter((root) => {
|
||||
const value = typeof root === "string" ? root.trim() : "";
|
||||
if (!value) return false;
|
||||
return resolve(configDir, value) !== resolve(workspacePath);
|
||||
});
|
||||
|
||||
const workspacesChanged = nextWorkspaces.length !== workspaces.length;
|
||||
const rootsChanged = nextRoots.length !== roots.length;
|
||||
if (!workspacesChanged && !rootsChanged) return false;
|
||||
async function persistServerWorkspaceState(config: ServerConfig): Promise<boolean> {
|
||||
const configPath = config.configPath?.trim() ?? "";
|
||||
if (!configPath) return false;
|
||||
if (!(await exists(configPath))) return false;
|
||||
|
||||
const parsed = await readServerConfigFile(configPath);
|
||||
const next: OpenworkServerConfigFile = {
|
||||
...parsed,
|
||||
...(workspacesChanged ? { workspaces: nextWorkspaces } : {}),
|
||||
...(rootsChanged ? { authorizedRoots: nextRoots } : {}),
|
||||
workspaces: config.workspaces.map(serializeWorkspaceConfigEntry),
|
||||
authorizedRoots: Array.from(new Set(config.authorizedRoots.map((root) => resolve(root)))),
|
||||
};
|
||||
|
||||
await ensureDir(dirname(configPath));
|
||||
@@ -3985,6 +4127,22 @@ async function persistWorkspaceDeletion(configPath: string, workspaceId: string,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpencodeScope(value: string | null | undefined): "project" | "global" {
|
||||
return value?.trim().toLowerCase() === "global" ? "global" : "project";
|
||||
}
|
||||
|
||||
function resolveOpencodeConfigFilePath(scope: "project" | "global", workspaceRoot: string): string {
|
||||
if (scope === "global") {
|
||||
const base = join(homedir(), ".config", "opencode");
|
||||
const jsoncPath = join(base, "opencode.jsonc");
|
||||
const jsonPath = join(base, "opencode.json");
|
||||
if (existsSync(jsoncPath)) return jsoncPath;
|
||||
if (existsSync(jsonPath)) return jsonPath;
|
||||
return jsoncPath;
|
||||
}
|
||||
return opencodeConfigPath(workspaceRoot);
|
||||
}
|
||||
|
||||
function normalizeOpenCodeRouterIdentityId(value: unknown): string {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) return "default";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type WorkspaceType = "local" | "remote";
|
||||
|
||||
export type RemoteType = "opencode" | "openwork";
|
||||
|
||||
export type ApprovalMode = "manual" | "auto";
|
||||
|
||||
export type TokenScope = "owner" | "collaborator" | "viewer";
|
||||
@@ -11,11 +13,22 @@ export type ProviderPlacement = "in-sandbox" | "host-machine" | "client-machine"
|
||||
export type LogFormat = "pretty" | "json";
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
id?: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
preset?: string;
|
||||
workspaceType?: WorkspaceType;
|
||||
remoteType?: RemoteType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
displayName?: string;
|
||||
openworkHostUrl?: string;
|
||||
openworkToken?: string;
|
||||
openworkWorkspaceId?: string;
|
||||
openworkWorkspaceName?: string;
|
||||
sandboxBackend?: string;
|
||||
sandboxRunId?: string;
|
||||
sandboxContainerName?: string;
|
||||
opencodeUsername?: string;
|
||||
opencodePassword?: string;
|
||||
}
|
||||
@@ -24,9 +37,19 @@ export interface WorkspaceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
preset: string;
|
||||
workspaceType: WorkspaceType;
|
||||
remoteType?: RemoteType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
displayName?: string;
|
||||
openworkHostUrl?: string;
|
||||
openworkToken?: string;
|
||||
openworkWorkspaceId?: string;
|
||||
openworkWorkspaceName?: string;
|
||||
sandboxBackend?: string;
|
||||
sandboxRunId?: string;
|
||||
sandboxContainerName?: string;
|
||||
opencodeUsername?: string;
|
||||
opencodePassword?: string;
|
||||
opencode?: {
|
||||
@@ -37,6 +60,12 @@ export interface WorkspaceInfo {
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpencodeConfigFile {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalConfig {
|
||||
mode: ApprovalMode;
|
||||
timeoutMs: number;
|
||||
|
||||
283
apps/server/src/workspace-init.ts
Normal file
283
apps/server/src/workspace-init.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { basename, join } from "node:path";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { upsertSkill } from "./skills.js";
|
||||
import { upsertCommand } from "./commands.js";
|
||||
import { readJsoncFile, writeJsoncFile } from "./jsonc.js";
|
||||
import { ensureDir, exists } from "./utils.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { openworkConfigPath, opencodeConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
|
||||
|
||||
const WORKSPACE_GUIDE = `---
|
||||
name: workspace-guide
|
||||
description: Workspace guide to introduce OpenWork and onboard new users.
|
||||
---
|
||||
|
||||
# Welcome to OpenWork
|
||||
|
||||
Hi, I'm Ben and this is OpenWork. It's an open-source alternative to Claude's cowork. It helps you work on your files with AI and automate the mundane tasks so you don't have to.
|
||||
|
||||
Before we start, use the question tool to ask:
|
||||
"Are you more technical or non-technical? I'll tailor the explanation."
|
||||
|
||||
## If the person is non-technical
|
||||
OpenWork feels like a chat app, but it can safely work with the files you allow. Put files in this workspace and I can summarize them, create new ones, or help organize them.
|
||||
|
||||
Try:
|
||||
- "Summarize the files in this workspace."
|
||||
- "Create a checklist for my week."
|
||||
- "Draft a short summary from this document."
|
||||
|
||||
## Skills and plugins (simple)
|
||||
Skills add new capabilities. Plugins add advanced features like scheduling or browser automation. We can add them later when you're ready.
|
||||
|
||||
## If the person is technical
|
||||
OpenWork is a GUI for OpenCode. Everything that works in OpenCode works here.
|
||||
|
||||
Most reliable setup today:
|
||||
1) Install OpenCode from opencode.ai
|
||||
2) Configure providers there (models and API keys)
|
||||
3) Come back to OpenWork and start a session
|
||||
|
||||
Skills:
|
||||
- Install from the Skills tab, or add them to this workspace.
|
||||
- Docs: https://opencode.ai/docs/skills
|
||||
|
||||
Plugins:
|
||||
- Configure in opencode.json or use the Plugins tab.
|
||||
- Docs: https://opencode.ai/docs/plugins/
|
||||
|
||||
MCP servers:
|
||||
- Add external tools via opencode.json.
|
||||
- Docs: https://opencode.ai/docs/mcp-servers/
|
||||
|
||||
Config reference:
|
||||
- Docs: https://opencode.ai/docs/config/
|
||||
|
||||
End with two friendly next actions to try in OpenWork.
|
||||
`;
|
||||
|
||||
const GET_STARTED_SKILL = `---
|
||||
name: get-started
|
||||
description: Guide users through the get started setup and Chrome DevTools demo.
|
||||
---
|
||||
|
||||
## When to use
|
||||
- Always load this skill when the user says "get started".
|
||||
|
||||
## What to do
|
||||
- Reply with these four lines, exactly and in order:
|
||||
1) hey there welcome this is openwork
|
||||
2) we've pre-configured you with a couple tools
|
||||
3) Get Started
|
||||
4) write "hey go on google.com"
|
||||
|
||||
## Then
|
||||
- If the user writes "go on google.com" (or "hey go on google.com"), use the chrome-devtools MCP to open the site.
|
||||
- After the navigation completes, reply: "I'm on <site>" where <site> is the final URL or page title they asked for.
|
||||
`;
|
||||
|
||||
const OPENWORK_AGENT = `---
|
||||
description: OpenWork default agent (safe, mobile-first, self-referential)
|
||||
mode: primary
|
||||
temperature: 0.2
|
||||
---
|
||||
|
||||
You are OpenWork.
|
||||
|
||||
When the user refers to "you", they mean the OpenWork app and the current workspace.
|
||||
|
||||
Your job:
|
||||
- Help the user work on files safely.
|
||||
- Automate repeatable work.
|
||||
- Keep behavior portable and reproducible.
|
||||
|
||||
Memory (two kinds)
|
||||
1) Behavior memory (shareable, in git)
|
||||
- ".opencode/skills/**"
|
||||
- ".opencode/agents/**"
|
||||
- repo docs
|
||||
|
||||
2) Private memory (never commit)
|
||||
- Tokens, IDs, credentials
|
||||
- Local DBs/logs/config files (gitignored)
|
||||
- Notion pages/databases (if configured via MCP)
|
||||
|
||||
Hard rule: never copy private memory into repo files verbatim. Store only redacted summaries, schemas/templates, and stable pointers.
|
||||
|
||||
Reconstruction-first
|
||||
- Do not assume env vars or prior setup.
|
||||
- If required state is missing, ask one targeted question.
|
||||
- After the user provides it, store it in private memory and continue.
|
||||
|
||||
Verification-first
|
||||
- If you change code, run the smallest meaningful test or smoke check.
|
||||
- If you touch UI or remote behavior, validate end-to-end and capture logs on failure.
|
||||
|
||||
Incremental adoption loop
|
||||
- Do the task once end-to-end.
|
||||
- If steps repeat, factor them into a skill.
|
||||
- If the work becomes ongoing, create/refine an agent role.
|
||||
- If it should run regularly, schedule it and store outputs in private memory.
|
||||
`;
|
||||
|
||||
type WorkspaceOpenworkConfig = {
|
||||
version: number;
|
||||
workspace?: {
|
||||
name?: string | null;
|
||||
createdAt?: number | null;
|
||||
preset?: string | null;
|
||||
} | null;
|
||||
authorizedRoots: string[];
|
||||
reload?: {
|
||||
auto?: boolean;
|
||||
resume?: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function normalizePreset(preset: string | null | undefined): string {
|
||||
const trimmed = preset?.trim() ?? "";
|
||||
if (!trimmed) return "starter";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function mergePlugins(existing: string[], required: string[]): string[] {
|
||||
const next = existing.slice();
|
||||
for (const plugin of required) {
|
||||
if (!next.includes(plugin)) {
|
||||
next.push(plugin);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function ensureOpenworkAgent(workspaceRoot: string): Promise<void> {
|
||||
const agentsDir = join(workspaceRoot, ".opencode", "agents");
|
||||
const agentPath = join(agentsDir, "openwork.md");
|
||||
if (await exists(agentPath)) return;
|
||||
await ensureDir(agentsDir);
|
||||
await writeFile(agentPath, OPENWORK_AGENT.endsWith("\n") ? OPENWORK_AGENT : `${OPENWORK_AGENT}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function ensureStarterSkills(workspaceRoot: string, preset: string): Promise<void> {
|
||||
await ensureDir(projectSkillsDir(workspaceRoot));
|
||||
await upsertSkill(workspaceRoot, {
|
||||
name: "workspace-guide",
|
||||
description: "Workspace guide to introduce OpenWork and onboard new users.",
|
||||
content: WORKSPACE_GUIDE,
|
||||
});
|
||||
if (preset === "starter") {
|
||||
await upsertSkill(workspaceRoot, {
|
||||
name: "get-started",
|
||||
description: "Guide users through the get started setup and Chrome DevTools demo.",
|
||||
content: GET_STARTED_SKILL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureStarterCommands(workspaceRoot: string, preset: string): Promise<void> {
|
||||
await ensureDir(projectCommandsDir(workspaceRoot));
|
||||
await upsertCommand(workspaceRoot, {
|
||||
name: "learn-files",
|
||||
description: "Safe, practical file workflows",
|
||||
template: "Show me how to interact with files in this workspace. Include safe examples for reading, summarizing, and editing.",
|
||||
});
|
||||
await upsertCommand(workspaceRoot, {
|
||||
name: "learn-skills",
|
||||
description: "How skills work and how to create your own",
|
||||
template: "Explain what skills are, how to use them, and how to create a new skill for this workspace.",
|
||||
});
|
||||
await upsertCommand(workspaceRoot, {
|
||||
name: "learn-plugins",
|
||||
description: "What plugins are and how to install them",
|
||||
template: "Explain what plugins are and how to install them in this workspace.",
|
||||
});
|
||||
if (preset === "starter") {
|
||||
await upsertCommand(workspaceRoot, {
|
||||
name: "get-started",
|
||||
description: "Get started",
|
||||
template: "get started",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOpencodeConfig(workspaceRoot: string, preset: string): Promise<void> {
|
||||
const path = opencodeConfigPath(workspaceRoot);
|
||||
const { data } = await readJsoncFile<Record<string, unknown>>(path, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
});
|
||||
const next: Record<string, unknown> = data && typeof data === "object" && !Array.isArray(data)
|
||||
? { ...data }
|
||||
: { $schema: "https://opencode.ai/config.json" };
|
||||
|
||||
if (typeof next.default_agent !== "string" || !next.default_agent.trim()) {
|
||||
next.default_agent = "openwork";
|
||||
}
|
||||
|
||||
const requiredPlugins = preset === "starter" || preset === "automation"
|
||||
? ["opencode-scheduler"]
|
||||
: [];
|
||||
if (requiredPlugins.length > 0) {
|
||||
const currentPlugins = Array.isArray(next.plugin)
|
||||
? next.plugin.filter((value: unknown): value is string => typeof value === "string")
|
||||
: typeof next.plugin === "string"
|
||||
? [next.plugin]
|
||||
: [];
|
||||
next.plugin = mergePlugins(currentPlugins, requiredPlugins);
|
||||
}
|
||||
|
||||
if (preset === "starter") {
|
||||
const currentMcp = next.mcp && typeof next.mcp === "object" && !Array.isArray(next.mcp)
|
||||
? { ...(next.mcp as Record<string, unknown>) }
|
||||
: {};
|
||||
if (!("control-chrome" in currentMcp)) {
|
||||
currentMcp["control-chrome"] = {
|
||||
type: "local",
|
||||
command: ["chrome-devtools-mcp"],
|
||||
};
|
||||
}
|
||||
next.mcp = currentMcp;
|
||||
}
|
||||
|
||||
await writeJsoncFile(path, next);
|
||||
}
|
||||
|
||||
async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: string): Promise<void> {
|
||||
const path = openworkConfigPath(workspaceRoot);
|
||||
if (await exists(path)) return;
|
||||
const now = Date.now();
|
||||
const config: WorkspaceOpenworkConfig = {
|
||||
version: 1,
|
||||
workspace: {
|
||||
name: basename(workspaceRoot) || "Workspace",
|
||||
createdAt: now,
|
||||
preset,
|
||||
},
|
||||
authorizedRoots: [workspaceRoot],
|
||||
reload: null,
|
||||
};
|
||||
await ensureDir(join(workspaceRoot, ".opencode"));
|
||||
await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: string): Promise<void> {
|
||||
const preset = normalizePreset(presetInput);
|
||||
if (!workspaceRoot.trim()) {
|
||||
throw new ApiError(400, "invalid_workspace_path", "workspace path is required");
|
||||
}
|
||||
await ensureDir(workspaceRoot);
|
||||
await ensureStarterSkills(workspaceRoot, preset);
|
||||
await ensureOpenworkAgent(workspaceRoot);
|
||||
await ensureStarterCommands(workspaceRoot, preset);
|
||||
await ensureOpencodeConfig(workspaceRoot, preset);
|
||||
await ensureWorkspaceOpenworkConfig(workspaceRoot, preset);
|
||||
}
|
||||
|
||||
export async function readRawOpencodeConfig(path: string): Promise<{ exists: boolean; content: string | null }> {
|
||||
const hasFile = await exists(path);
|
||||
if (!hasFile) {
|
||||
return { exists: false, content: null };
|
||||
}
|
||||
const content = await readFile(path, "utf8");
|
||||
return { exists: true, content };
|
||||
}
|
||||
@@ -2,24 +2,69 @@ import { createHash } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
|
||||
|
||||
export function workspaceIdForPath(path: string): string {
|
||||
const hash = createHash("sha256").update(path).digest("hex");
|
||||
function workspaceIdForKey(key: string): string {
|
||||
const hash = createHash("sha256").update(key).digest("hex");
|
||||
return `ws_${hash.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function workspaceIdForPath(path: string): string {
|
||||
return workspaceIdForKey(path);
|
||||
}
|
||||
|
||||
export function workspaceIdForRemote(baseUrl: string, directory?: string | null): string {
|
||||
const normalizedBaseUrl = baseUrl.trim();
|
||||
const normalizedDirectory = directory?.trim() ?? "";
|
||||
const key = normalizedDirectory
|
||||
? `remote::${normalizedBaseUrl}::${normalizedDirectory}`
|
||||
: `remote::${normalizedBaseUrl}`;
|
||||
return workspaceIdForKey(key);
|
||||
}
|
||||
|
||||
export function workspaceIdForOpenwork(hostUrl: string, workspaceId?: string | null): string {
|
||||
const normalizedHostUrl = hostUrl.trim();
|
||||
const normalizedWorkspaceId = workspaceId?.trim() ?? "";
|
||||
const key = normalizedWorkspaceId
|
||||
? `openwork::${normalizedHostUrl}::${normalizedWorkspaceId}`
|
||||
: `openwork::${normalizedHostUrl}`;
|
||||
return workspaceIdForKey(key);
|
||||
}
|
||||
|
||||
export function buildWorkspaceInfos(
|
||||
workspaces: WorkspaceConfig[],
|
||||
cwd: string,
|
||||
): WorkspaceInfo[] {
|
||||
return workspaces.map((workspace) => {
|
||||
const resolvedPath = resolve(cwd, workspace.path);
|
||||
const rawPath = workspace.path?.trim() ?? "";
|
||||
const workspaceType = workspace.workspaceType ?? "local";
|
||||
const resolvedPath = rawPath ? resolve(cwd, rawPath) : "";
|
||||
const remoteType = workspace.remoteType;
|
||||
const id = workspace.id?.trim()
|
||||
|| (workspaceType === "remote"
|
||||
? remoteType === "openwork"
|
||||
? workspaceIdForOpenwork(workspace.openworkHostUrl ?? workspace.baseUrl ?? "", workspace.openworkWorkspaceId)
|
||||
: workspaceIdForRemote(workspace.baseUrl ?? "", workspace.directory)
|
||||
: workspaceIdForPath(resolvedPath));
|
||||
const name = workspace.name?.trim()
|
||||
|| workspace.displayName?.trim()
|
||||
|| workspace.openworkWorkspaceName?.trim()
|
||||
|| basename(resolvedPath || workspace.directory?.trim() || workspace.baseUrl?.trim() || "Workspace");
|
||||
return {
|
||||
id: workspaceIdForPath(resolvedPath),
|
||||
name: workspace.name ?? basename(resolvedPath),
|
||||
id,
|
||||
name,
|
||||
path: resolvedPath,
|
||||
workspaceType: workspace.workspaceType ?? "local",
|
||||
preset: workspace.preset?.trim() || (workspaceType === "remote" ? "remote" : "starter"),
|
||||
workspaceType,
|
||||
remoteType,
|
||||
baseUrl: workspace.baseUrl,
|
||||
directory: workspace.directory,
|
||||
displayName: workspace.displayName,
|
||||
openworkHostUrl: workspace.openworkHostUrl,
|
||||
openworkToken: workspace.openworkToken,
|
||||
openworkWorkspaceId: workspace.openworkWorkspaceId,
|
||||
openworkWorkspaceName: workspace.openworkWorkspaceName,
|
||||
sandboxBackend: workspace.sandboxBackend,
|
||||
sandboxRunId: workspace.sandboxRunId,
|
||||
sandboxContainerName: workspace.sandboxContainerName,
|
||||
opencodeUsername: workspace.opencodeUsername,
|
||||
opencodePassword: workspace.opencodePassword,
|
||||
};
|
||||
|
||||
1
ee/apps/den-web/tsconfig.tsbuildinfo
Normal file
1
ee/apps/den-web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user