Skip to content

Projects and workspaces — model and API

Audience: developer working on projects/workspaces CRUD or integrating the .arka-deck/ tree.


Workspace (group — metadata only)
├── Project A (= disk folder + marker .arka-deck/project/marker.json)
├── Project B
└── Project C
  • Workspace: pure metadata. Persisted in the global user index.
  • Project: a disk folder on the machine. The disk marker (<projectPath>/.arka-deck/project/marker.json) is the source of truth.
  • Global index: <arkaHome>/index/projects.json + workspaces.json = reconstructible aggregated view.

Default path: ~/.arka-deck/ (override via env ARKA_DECK_HOME).


interface Workspace {
id: string; // UUID v4
name: string; // unique in index, ≤ 80 chars
description: string;
createdAt: string; // ISO 8601
}

Persisted only in <arkaHome>/index/workspaces.json.

interface Project {
id: string; // UUID v4
name: string; // display label, ≤ 80 chars
description: string;
path: string; // absolute folder path, IMMUTABLE
workspaceId: string;
createdAt: string;
lastUsedAt: string; // updated on each open
}

Persisted twice:

  • Disk marker: <projectPath>/.arka-deck/project/marker.json ← source of truth
  • Global index: <arkaHome>/index/projects.json ← reconstructible from markers

On conflict (marker says X, index says Y) → marker wins (cf. findByCwd which auto-reimports the index if out-of-sync).


PortRole
ProjectStoreReads/writes the disk marker
ProjectIndexStoreReads/writes the global user index
WorkspaceStoreReads/writes the global workspaces index
InstalledItemsStoreReads installed items list (agents/hooks/skills) — used by purge
ChatSessionStoreReads/deletes linked chat sessions (used by forget/purge)
FilesystemFS wrapper (read/write/exists/rm/readdir/…)

ForProjects:

  • create({name, path, description, workspaceId}) — creates marker + index
  • list() — all projects, lastUsedAt desc order
  • listByWorkspace(workspaceId) — filter
  • getById(id) — index lookup
  • forget(id) — removes from index, disk marker preserved
  • update({id, name, description, workspaceId}) — patch (path immutable)
  • touchLastUsed(id) — updates lastUsedAt
  • findByCwd(cwd) — auto-detect at boot
  • purge(id) — forget + cleanup .arka-deck/ + installed agents + chat sessions

ForWorkspaces:

  • create({name, description}) — name must be unique
  • list() — all, newest first
  • getById(id)
  • update({id, name, description}) — rechecks uniqueness
  • delete(id) — refuses if attached projects (WorkspaceNotEmptyError)
AdapterFile
FsProjectStoreadapters/outbound/filesystem/fs-project-store.ts
FsProjectIndexStoreadapters/outbound/filesystem/fs-project-index-store.ts
FsWorkspaceStoreadapters/outbound/filesystem/fs-workspace-store.ts

~/.arka-deck/ ← arkaHome (override via ARKA_DECK_HOME)
index/
workspaces.json
projects.json
preferences/
user.json
security.json ← allowed-path allowlist
cache/
catalogue/...
debrief/...
providers/
providers.db ← provider instances SQLite
secrets/
secrets.key ← AES-256-GCM master key (mode 600)
chat/
chat.db ← chat sessions/messages SQLite
<projectPath>/
.arka-deck/
project/
marker.json ← project source of truth
agents/installed.json
hooks/installed.json
skills/installed.json
memory/ ← cf. addons-firstparty/memory-local
.claude/
agents/<slug>/ ← recruited agent (HYOS profile)
settings.json ← Claude Code settings (never touched)

ForProjects.create({ name, path, description, workspaceId })
[Check path = disk directory]
[Check marker absent (anti-duplicate)]
[Check workspaceId exists]
[Filesystem.mkdir <path>/.arka-deck/project/, recursive]
[ProjectStore.save → marker.json]
[ProjectIndexStore.save → append to projects.json]
EventBus → 'project.created' (async)
ForProjects.findByCwd(cwd)
[ProjectStore.exists(cwd) ? = marker lookup]
├─ Marker absent → null (not an arka-deck project)
└─ Marker present
[Read marker → Project]
[ProjectIndexStore.getById → present?]
├─ Yes → returns project
└─ No → re-imports (index is reconstructible) + returns project

Typical case: a user git clones an existing project on another machine. arka-deck detects the marker at next boot and adds it to its index.

ModeIndexDisk marker.arka-deck/Agents .claude/agents/Chat sessions SQLite
forgetremovedpreservedpreservedpreserveddeleted
purgeremoveddeleteddeleteddeleted (only arka-deck installed)deleted

purge never touches non-arka-deck .claude/ files (user settings, non-arka agents). Only artifacts tracked in installed.json.

Code: core/use-cases/projects/build-for-projects.ts:purgeProjectUseCase.

The web server checks a project creation path is in the user allowlist before writing the marker. Default allowlist: ~, ~/Documents, ~/Projects, etc. — configurable in ~/.arka-deck/preferences/security.json.

Refusal → HTTP 403 PATH_NOT_ALLOWED with a body listing allowed locations.


forWorkspaces.create({ name: 'Arkalabs', description: 'Main workspace' });
  • name must be globally unique (no per-user — single-user app)
  • Raises WorkspaceNameAlreadyExistsError otherwise
await forWorkspaces.delete(workspaceId);
// → throws WorkspaceNotEmptyError if attached projects

No automatic cascade. The user must explicitly move or delete their projects first.

The UI WorkspaceDeleteTunnel:

  • Lists attached projects if non-empty
  • Disables the “Delete” button while attachedProjects.length > 0
  • Offers a “Go to projects” shortcut

GET /api/workspaces → list
GET /api/workspaces/:id → detail
POST /api/workspaces → create
PUT /api/workspaces/:id → patch
DELETE /api/workspaces/:id → delete (refuses if non-empty)
GET /api/workspaces/:id/projects → projects of this workspace
GET /api/projects → list all projects
GET /api/projects/:id → detail
GET /api/projects/find-by-cwd?path=... → auto-detect by marker
POST /api/projects → create (checks allowlist + workspace exists)
PUT /api/projects/:id → patch (path immutable)
DELETE /api/projects/:id → forget (204)
DELETE /api/projects/:id?purge=true → full purge, returns {deletedPaths}
POST /api/projects/:id/touch → touchLastUsed (200 + DTO)

TunnelStepsScope
WorkspaceTunnel3 (name → description → confirm)Creation
WorkspaceEditTunnel3Edit
WorkspaceDeleteTunnel2 (check non-empty → confirm)Deletion blocked if attached projects
ProjectTunnel5 (path → name → desc → workspace → confirm)Creation with FolderPicker + auto-suggest server cwd + conflict alert
ProjectEditTunnel4 (name → desc → workspace → confirm)Edit (path immutable)
ProjectDeleteTunnel3 (forget/purge mode → confirm → result)Deletion with mode choice + deletedPaths recap

All tunnels expose the blocking loader overlay during long mutations (busy={isSubmitting}).

  • HomeView: project grid of the current workspace + workspace header banner (Edit / Delete directly accessible).
  • ProjectView: project detail — installed agents, chat sessions, actions (Edit project / Launch options / Delete this project).
  • ContextBand (top bar): workspace + project selector + Edit/Delete actions in the workspace dropdown footer.

The projects/workspaces module publishes on the arka-deck bus:

EventModePayload
workspace.createdasync{ workspaceId, name, createdAt }
workspace.updatedasync{ workspaceId, name, description }
workspace.deletedsync{ workspaceId }
project.createdasync{ projectId, projectPath, workspaceId, name }
project.updatedasync{ projectId, name, workspaceId }
project.forgottensync{ projectId }
project.purgedsync{ projectId, projectPath, deletedPaths }
project.last-usedasync{ projectId, lastUsedAt }
project.detected-by-cwdasync{ projectId, projectPath, cwd }

Expected subscribers: memory (session cleanup on purge), governance (creation audit), squad (recompose if project added).


If your module places files in <projectPath> (other than .arka-deck/ which is cleaned by default), implement a cleanup on the purgeProjectUseCase side.

Section titled “Option A — Subscribe to project.purged (recommended)”
bus.subscribe('project.purged', async (event) => {
await myStore.cleanupProject(event.projectPath);
});

Modify core/use-cases/projects/build-for-projects.ts to accept a new optional dep and call its cleanup. Prefer Option A to respect decoupling.


GuaranteeMechanism
No path traversalpathPolicy.isAllowed(path) on the HTTP route side before writing marker
User allowlist~/.arka-deck/preferences/security.json — validated glob patterns
No write outside .arka-deck/ on createUse-case only touches <projectPath>/.arka-deck/project/marker.json
Purge does not touch user filesCleanup limited to items tracked in installed.json
Atomic marker + index writetmp + rename POSIX
Corruption detectionMarkerCorruptedError HTTP 422 — marker is unusable, index remains source

The disk marker is not exposed by HTTP routes. The UI consumes server DTOs only via /api/projects (the aggregated index).

// ❌
const proj = await projectIndexStore.list().find((p) => p.path === cwd);
// ✅
const proj = await forProjects.findByCwd(cwd);

The path is immutable on Project.create. To change the path:

  1. forget the old project (preserves the marker)
  2. create a new project at the new path
  3. Manually migrate .arka-deck/ files if needed
// ❌
await forWorkspaces.delete(wid);
forceDeleteAll(wid);
// ✅
const projects = await forProjects.listByWorkspace(wid);
await Promise.all(projects.map((p) => forProjects.forget(p.id)));
await forWorkspaces.delete(wid);

For absolute truth on a project’s state, read the disk marker (projectStore.load(path)). The index may be out-of-sync.


ActionFile
Domain Projectcore/domain/project/project.ts
Domain Workspacecore/domain/workspace/workspace.ts
Projects use-casescore/use-cases/projects/build-for-projects.ts
Workspaces use-casescore/use-cases/workspaces/build-for-workspaces.ts
Marker storeadapters/outbound/filesystem/fs-project-store.ts
Index storeadapters/outbound/filesystem/fs-project-index-store.ts
Workspace storeadapters/outbound/filesystem/fs-workspace-store.ts
Path policyadapters/outbound/system/preferences-path-policy.ts
Server routesadapters/inbound/web/server/src/routes/{projects,workspaces}.ts
UI tunnelsadapters/inbound/web/ui/src/components/tunnel/*Tunnel.tsx
UI hooksadapters/inbound/web/ui/src/hooks/{useProjects,useWorkspaces}.ts
Errorscore/domain/errors.ts