mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
v0.2.9
268 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b291db11c2 |
feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
|
||
|
|
cf74327aa6 |
chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim
We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.
Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:
- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
build_ms so we can tell whether the slowness is in the actual claim
query or the post-claim response assembly (GetAgent, LoadAgentSkills,
GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
branch's 4 queries).
- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
list_pending_count, agents_tried, claim_loop_ms — directly validates
the suspected N+1 amplification (one ListPendingTasksByRuntime + one
ClaimTask per unique agent).
- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
LOCKED cost from the surrounding metadata reads.
Pure observability change. No behavior change in the request path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): widen claim slow-log to cover post-claim DB work and error paths
Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.
Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
and dispatch_ms phase fields. Error paths now also emit a slow log
with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
error paths now also carry phase timings (outcome=unauth / error_claim).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
951f51408a |
fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard Resumed Claude sessions keep prior turns' tool calls in context, so a comment-triggered task could reuse the PREVIOUS turn's --parent UUID instead of the current trigger's. The reply landed in the wrong thread (MUL-1125): backend stored exactly what the agent sent, but the agent pulled a stale UUID from its own conversation memory. Two layers of defense: 1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt and execenv.InjectRuntimeConfig emit the same "use this exact --parent, do not reuse values from previous turns" block. The per-turn prompt now carries the current TriggerCommentID, which it previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read mid-session). 2. Handler-side guard in CreateComment: when an agent posts from inside a comment-triggered task (X-Agent-ID + X-Task-ID, task has TriggerCommentID), require parent_id == task.TriggerCommentID or return 409. Assignment-triggered tasks are untouched. * fix(agent/comments): scope parent_id guard to the task's own issue Two issues from CI + GPT-Boy's review: 1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an agent legitimately commenting on a different issue while its current task was comment-triggered would get 409'd with the wrong issue's trigger comment id. Narrow the guard to fire only when the request's issue matches the task's own issue — cross-issue agent activity stays unblocked. 2. The integration test tried to insert a second queued task for the same (agent, issue), which hits the idx_one_pending_task_per_issue_agent unique index. Replace the assignment-triggered-task sub-case with a cross-issue regression test (the scenario we now need to cover anyway): post on issue B while X-Task-ID points at a comment-triggered task on issue A, expect 201. |
||
|
|
c0be1b7ce9 |
fix(slugs): audit admin/multica/new/www + reserve in slug list (MUL-972) (#1359)
Follow-up to PR #1188 / migration 047, which intentionally omitted the five historical conflict slugs (admin / multica / new / setup / www) from the reserved-slug audit because each had one production workspace using it at the time and we did not want to block deploy on owner outreach. MUL-972 closed that loop on prd for four of the five: * admin (99cd10e4-…) → renamed to legacy-admin-99cd10e4 * multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa * new (e391e3ed-…) → renamed to legacy-new-e391e3ed * www (5e8d38b2-…) → workspace deleted (was empty: 0 issues / projects / agents, owner-only member; 18 workspace-FK relations all CASCADE) This PR: 1. Adds migration 049_audit_legacy_reserved_slugs which audits those four slugs against workspace.slug at startup. If any future workspace slips in with one of them, startup fails loudly via RAISE EXCEPTION instead of being silently shadowed by a global route. Mirrors the structure of 047. 2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list in both the Go handler and the shared TS list (admin was already in both). Keeps the two lists in lockstep per the convention enforced in workspace_reserved_slugs.go header. setup is STILL exempt from the audit and is intentionally NOT added to the reserved list. The setup workspace (b43f0bc2-…) is a real production user (owner: Roberto Betancourth, building a chants/Alabanzas app) and is being handled out-of-band via owner outreach. A separate follow-up migration will fold setup into the audit once that workspace's slug has been migrated. Migration is intentionally shipped AFTER the prd data fix (not before): 049 will RAISE EXCEPTION on any remaining conflict, so we want the data state clean first. Rollout order: prd data fix (done by db-boy on 2026-04-20) → this PR. Tested: - go test ./server/internal/handler/ -run TestReserved → pass - pnpm --filter @multica/core test consistency → pass (4/4 in consistency.test.ts; global-prefix↔reserved invariant holds) Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
5fa1da448f |
fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures The chat 'forgets earlier messages' bug came from PriorSessionID being silently lost in several edge cases: - UpdateChatSessionSession unconditionally overwrote chat_session.session_id, so any task that completed without a session_id (early agent crash, missing result) wiped the resume pointer to NULL. - CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A follow-up chat message claimed in between resumed against a stale (or NULL) session and started over. - FailAgentTask never wrote session_id back, so a task that established a real session before failing lost its resume pointer. - ClaimTaskByRuntime only trusted chat_session.session_id and never fell back to the existing GetLastChatTaskSession query, so a single bad turn could permanently drop the conversation memory. This change: - Use COALESCE in UpdateChatSessionSession so empty inputs preserve the existing pointer; surface DB errors instead of swallowing them. - Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside the same transaction (TaskService now takes a TxStarter). - Extend FailAgentTask + the daemon FailTask path (client, handler, service) to forward session_id/work_dir, so failed/blocked tasks that built a real session still record it. - Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the chat_session pointer is missing, and include failed tasks in that lookup so a single failure can't lose the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(daemon): forward session_id/work_dir on blocked + timeout paths runTask previously dropped result.SessionID and env.WorkDir on the non-completed return paths: - timeout returned a naked error, so handleTask called FailTask with empty session info and the chat resume pointer was either left stale or eventually overwritten with NULL. - blocked / failed (default branch) returned a TaskResult without SessionID / WorkDir, so even though FailTask now COALESCEs into chat_session, there was no value to write through. - the empty-output completion path was the same: it raised an error even when a real session_id had been built. All three paths now return a TaskResult that carries the SessionID / WorkDir the backend produced. Combined with the COALESCE-based update in UpdateChatSessionSession and the FailTask plumbing introduced in PR #1360, the next chat turn can always resume from the latest agent session — even when the previous turn timed out, was rate-limited, or returned an empty completion — instead of starting over with no memory of the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): capture session id from session.start as fallback The Copilot backend only read sessionId from the synthetic 'result' event, ignoring the one already present on session.start. When the CLI was killed before result arrived (timeout, cancel, crash, or a session.error mid-turn), the daemon reported SessionID="" and the chat-session resume pointer could not advance — causing the chat to silently drop conversation memory on the next turn. Capture session.start.sessionId into state up front, and only let 'result' overwrite it when it actually carries one. result still wins when present (it is the authoritative end-of-turn record). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): parse premiumRequests as float to preserve session id Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5), not an integer. Our copilotResultUsage struct typed it as int, which made the entire 'result' line fail json.Unmarshal — silently dropping sessionId on every turn. This was the real cause of chat memory loss: the daemon reported SessionID="" to the server, chat_session.session_id stayed NULL, and the next chat turn never received --resume <id>, so each turn started a fresh Copilot session with no prior context. Add a regression test using the real JSON line from CLI v1.0.32 that asserts sessionId is preserved when premiumRequests is fractional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Eve <eve@multica.ai> Co-authored-by: yushen <ldnvnbl@gmail.com> |
||
|
|
b428f36ca6 |
feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930 - Added environment variables to control signups - Updated frontend to hide signup text when disabled - Added backend check to block new user creation via magic link - Updated .env.example |
||
|
|
163f34f918 |
feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type Each backend in server/pkg/agent/ hardcodes a stable command skeleton (e.g. `codex app-server --listen stdio://`, `hermes acp`) before appending opts.CustomArgs. Surfacing that skeleton lets the UI tell users which command their custom_args are being appended to, so a Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to reach the CLI when the subcommand is actually `app-server`. Expose only the minimum that aids judgment — binary + subcommand, or a short mode label when there is no subcommand — and deliberately omit transport values, internal flags, and env to keep the surface small and renaming-safe. Refs #1308. * feat(handler/runtime): surface launch_header on runtime response runtimeToResponse now derives launch_header from agent.LaunchHeader, piggybacking on the runtime's existing provider field so the frontend's RuntimeDevice gains the skeleton without a new endpoint or DB query. Client gets the header for free whenever it lists agents' runtimes — which the custom-args tab already does. Refs #1308. * feat(ui/agents): show launch mode preview in custom args tab Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab and render its launch_header as a one-line preview above the args list, so users see `codex app-server <your args>` (or equivalent per provider) and can tell whether a CLI-style flag like `--model` will actually reach the invoked subcommand. Source of truth stays in the Go backend; the TS type just carries the string. Refs #1308. |
||
|
|
d81e6a14a6 |
fix(comment): assignee on_comment path should use reply id, not thread root (#1302)
* fix(comment): assignee on_comment path should use reply id, not thread root Symmetric fix to #871 — that PR fixed the @mention path but missed the assignee on_comment path in the same file. Replies on agent-assigned issues were still getting trigger_comment_id = parent_id, so the daemon fed the parent comment's content to the resumed claude session, which then either exited with 'Already replied to comment <parent>' or silently misrouted its answer depending on model / session state. Reply placement (flat-thread grouping) is already decoupled from trigger_comment_id by TaskService.createAgentComment's parent normalization (added alongside #871), so passing comment.ID directly is safe and matches the mention path's post-#871 behavior. Fixes #1301 Made-with: Cursor * test(comment): assert assignee on_comment records reply id as trigger_comment_id Integration regression guard for #1301. Asserts that after a member posts a reply under an agent-authored thread, the enqueued agent task's trigger_comment_id matches the new reply, not the thread root. Without the companion fix in comment.go the old parent-override would store the root id and the daemon would feed stale content (via prompt.go BuildPrompt) to the agent. Made-with: Cursor --------- Co-authored-by: fuxiao <fuxiao@zyql.com> |
||
|
|
aa9305f7e4 |
fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1294)
* fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1276) * test: add regression test for #1276 — ClaimTaskByRuntime autopilot workspace_id |
||
|
|
63800f05ff |
fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access Closes #1111 The --strict-mcp-config flag was added defensively in #592 to prevent Claude agents from inheriting MCP state from the outer Claude Code session. It was meant to be paired with --mcp-config <path> to inject a controlled set of MCPs, but that path was never implemented, which silently stripped all user-scope MCPs from spawned agents. This PR completes the original design by: - Adding a nullable mcp_config jsonb column to the agents table - Wiring mcp_config through AgentResponse, Create/Update requests - Piping it into ExecOptions.McpConfig in the daemon - Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs - Blocklisting --mcp-config in claudeBlockedArgs to prevent override via custom_args Does not touch Codex provider (tracked separately in #674). Does not implement Multica MCP auto-injection (out of scope). * fix: disambiguate JSON null vs absent for mcp_config |
||
|
|
9b45e0d4a6 |
feat(cli): add issue subscriber commands (#1265)
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
|
||
|
|
a73336dcf8 |
feat(daemon): persistent UUID identity + legacy-id merge at register-time (#1220)
* feat(daemon): persistent UUID identity + legacy-id merge at register-time daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on first start, replacing the hostname-derived id that drifted whenever `.local` appeared/disappeared, a system was renamed, or a profile switched — each of which used to mint a fresh `agent_runtime` row and strand agents on the old one. To migrate existing installs without operator intervention, the daemon reports every legacy id it may have registered under previously (`host`, `host` with `.local` stripped, and `host[-profile]` variants for both). At register-time the server looks up each candidate row scoped to (workspace, provider), re-points its agents and tasks onto the new UUID-keyed row, records which legacy id was subsumed in the new `legacy_daemon_id` column for audit, and deletes the stale row. Result: users running `xxx.local`-keyed runtimes today transparently land on the new UUID row on next daemon restart. The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'` compatibility shim is no longer needed and has been removed along with the handler call that invoked it. * fix(daemon): handle bidirectional .local drift and case drift in legacy merge Review on #1220 flagged two gaps in the legacy-id migration candidate set: 1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the current hostname ended in `.local`. The opposite direction — DB has `foo.local`, current host is `foo` — was missed, so runtimes registered under the `.local` variant stayed orphaned after upgrade. Now both variants (`foo` and `foo.local`) are always emitted, regardless of what `os.Hostname()` currently returns, plus their `-<profile>` suffix forms. 2. Case drift: os.Hostname() has been observed returning different casings on the same machine across mDNS/reboot state. A case-sensitive `=` comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing differences merge rather than orphan. The (workspace_id, provider) prefix still bounds the scan to a tiny set of rows so the non-indexed LOWER() comparison has negligible cost. Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases; daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift. * fix(daemon): consolidate every case-duplicate legacy runtime, not just the first Follow-up review on #1220: after switching to `LOWER(daemon_id) = LOWER(@daemon_id)`, the single-row lookup still only merged one legacy row per candidate. If a machine already had two rows in the DB that differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND `jiayuans-macbook-pro.local` coexisting because earlier hostname drift already minted a duplicate), only one of them got consolidated and the other stayed orphaned — violating the "no duplicate runtime per machine after backfill" acceptance. - FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many) - mergeLegacyRuntimes iterates every returned row and dedupes across overlapping legacy candidates so `foo` and `foo.local` both resolving to the same stored row don't double-process Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two case-duplicate rows with one agent each and confirms both rows are deleted and both agents end up on the new UUID-keyed row. |
||
|
|
5a6a44a69e |
refactor(daemon): consolidate task workspace resolver + regression test (#1259)
Follow-up to #1249. Two small follow-ups requested in review: 1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and `service/task.go`. #1249 fixed the handler copy but left both in place, meaning any future branch (e.g. a fourth task link type) still needs to be added in two files. Promote the service method to the exported `TaskService.ResolveTaskWorkspaceID` and delete the handler copy. Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now call through `h.TaskService`. 2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace` covering the exact scenario from #1224: a task linked only via `AutopilotRunID` must resolve to the autopilot's workspace. The test asserts 404 for a cross-workspace daemon token and 200 (with status transitioning to `running`) for the correct-workspace token. |
||
|
|
9e15b17c92 |
feat(cli): add autopilot commands (#1234)
* feat(cli): add autopilot commands Expose the existing autopilot REST API through the multica CLI so users and agents can list, get, create, update, delete, trigger, and inspect autopilots, plus manage their triggers (schedule/webhook/api). Also surface the read + core write commands in the agent meta skill prompt so agents discover them without needing --help. - new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints - add APIClient.PatchJSON (autopilot update uses PATCH) - expose autopilot in CORE COMMANDS group - extend runtime_config.go meta skill with autopilot entries - document autopilot command group in CLI_AND_DAEMON.md * fix(autopilot): address code review — restrict run_only, validate workspace on update Code review caught two issues with the initial CLI PR: 1. run_only mode is broken end-to-end. The daemon-side resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves workspace from issue/chat, so run_only tasks (which have neither) return 404 from /start. BuildPrompt() would also emit an empty issue ID. The service-level resolver in internal/service/task.go already handles AutopilotRunID, but the daemon endpoint uses the handler copy. Fixing that path is out of scope for the CLI PR; drop run_only from the CLI and docs so we don't recommend a mode that cannot complete. Server continues to accept it for the existing UI. 2. UpdateAutopilot did not verify that a new assignee_id belongs to the workspace, unlike CreateAutopilot. This let a PATCH swap in an agent from a different workspace. Mirror the same GetAgentInWorkspace check. |
||
|
|
ea02a394dc |
fix(daemon): resolve workspace ID for autopilot run_only tasks (#1224) (#1249)
resolveTaskWorkspaceID only handled tasks linked via IssueID or ChatSessionID. Tasks created by run_only autopilots (introduced in #1028) have only AutopilotRunID set, so the resolver returned an empty workspace ID, causing requireDaemonTaskAccess to respond with 404. Add an AutopilotRunID branch that looks up the autopilot run, then its parent autopilot, to obtain the workspace ID. |
||
|
|
3ea6b5c7b8 |
fix(agent): return 409 on duplicate agent name (#1182)
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated) - CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique - Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying - Down migration drops the constraint only; deleted rows and cascaded data are not restored Co-authored-by: Anup Joy <joyanup@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
209300c86f |
fix(server): trigger agent on comments regardless of issue status (#1209)
Previously shouldEnqueueOnComment suppressed agent triggers on done/ cancelled issues, requiring an explicit @mention to resume the conversation. The gate was non-obvious and confused users who expected a regular reply to wake the agent up. Drop the status check — comments are conversational and should wake the agent up at any status. @mention already bypasses all gates, so behavior for mentions is unchanged. Refs multica-ai/multica#1205 |
||
|
|
6d6bc5a6f2 |
fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list (#1188)
* fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list
Two related changes:
1. Rename the global workspace-creation route from /new-workspace to
/workspaces/new. The hyphenated word-group `new-workspace` is a
common user workspace name (last deploy was blocked by a real user
with exactly this slug). Industry consensus from auditing Linear,
Vercel, Notion, Slack, GitHub: zero major SaaS uses hyphenated
word-group root routes — they all use single words or `/{noun}/{verb}`
pairs. Reserving the noun `workspaces` automatically protects the
entire `/workspaces/*` subtree, so future workspace-related routes
(`/workspaces/{id}/edit`, `/workspaces/{id}/billing`, etc.) need no
additional reserved slugs or audit migrations.
2. Extend the reserved slug list to cover the minimal set recommended by
the URL-design audit: full auth flow vocab, RFC 2142 mailbox names
(postmaster, abuse, noreply...), hostname confusables (mail, ftp,
static, cdn...), and likely-future platform routes (docs, support,
status, legal, privacy, terms, security, etc.). Production data
audit confirmed zero conflicts for every newly added slug, so
migration 047 (the safety net) passes cleanly.
Slugs intentionally NOT added despite being in scope of the audit:
admin, multica, new, setup, www. Each has one production workspace
already using it; adding them now would block deploy. They will be
handled in a follow-up PR via owner outreach + targeted rename.
Also adds a CLAUDE.md convention rule: new global routes MUST use a
single word or `/{noun}/{verb}` pair, never hyphenated word groups.
This prevents the pattern from regenerating itself.
This PR does NOT resolve the currently-blocked prd deploy — that requires
the existing `slug='new-workspace'` workspace (owner: Dhruv Raina) to be
renamed by ops. After that workspace is renamed and migration 046 passes,
this PR's migration 047 will also pass on its first run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: drop migration 046, sweep stale comments, drive reserved test from map
Address code review on PR #1188:
1. Delete migration 046 (audit_new_workspace_slug). It audits "new-workspace"
which is no longer a reserved slug after this PR's rename. Removing 046
has an unexpected upside: it directly unblocks the currently-stuck prd
deploy. Migration 046 had never successfully applied (it was the source
of the deploy block); the audit-only nature means down-rollback is a
no-op. The user workspace previously caught by 046 (slug='new-workspace',
owner: Dhruv Raina) is now safe — `new-workspace` is no longer reserved,
so the slug correctly resolves to that workspace and the global route
`/workspaces/new` doesn't shadow it.
2. Refactor workspace_test.go to drive its reserved-slug list from the
reservedSlugs map directly via `for slug := range reservedSlugs`. The
previous hand-copied list was already drifting (40-ish entries vs 58 in
the map). Now drift is impossible.
3. Sweep ~10 stale `/new-workspace` references in code comments to
`/workspaces/new`. Comments only — runtime unchanged. The references
in reserved-slugs.ts/workspace_reserved_slugs.go and CLAUDE.md are
intentionally kept as anti-pattern examples ("don't add hyphenated
word-group root routes like /new-workspace").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
6a2432b16b |
refactor: remove onboarding flow, fix daemon zero-workspace bootstrap (#1175)
* fix(daemon): allow startup with zero workspaces The daemon used to fail fast with "no runtimes registered" when the initial workspace sync returned zero workspaces. This masked a latent bug: a newly-signed-up user has no workspaces yet, so the daemon would crash immediately after login instead of waiting for the first workspace to be created. workspaceSyncLoop already polls every 30s (daemon.go:107, 365) to discover new workspaces — the fail-fast check at startup was bypassing this dynamic discovery. Remove the check so the daemon stays resident and picks up the first workspace whenever it appears. PR #1001 partially addressed this for the "server has workspaces but local CLI config is empty" case. This finishes the job for the true zero-workspace state, which until now was masked by the onboarding wizard always creating a workspace before the daemon started. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(views): extract CreateWorkspaceForm for reuse Modal and the upcoming /new-workspace page share the same form + mutation + slug validation. Extract to a shared component so they can't drift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(views): add NoAccessPage for unknown or inaccessible workspace slugs Rendered when the URL slug doesn't resolve to a workspace the user has access to. Deliberately doesn't distinguish 404 vs 403 to avoid letting attackers enumerate workspace slugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(paths): add /new-workspace route and reserve slug on both sides Adds paths.newWorkspace() builder, registers /new-workspace as a global (pre-workspace) prefix, and reserves the "new-workspace" slug on both frontend and backend (kept in sync per convention). Existing "onboarding" reservation retained — removing it would desync FE/BE and leaves no future fallback if an onboarding route is revived. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(migrations): audit no existing workspace uses 'new-workspace' slug Migration 046 blocks deploy if any workspace in the DB has slug = 'new-workspace', which would shadow the new global workspace creation route at /new-workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add /new-workspace route on web and desktop Renders the CreateWorkspaceForm as a full-page workspace creation flow, used as the destination for first-time users with zero workspaces. Replaces the 4-step onboarding wizard with a single form. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show NoAccessPage on unknown workspace slug, hold null during active removal Layouts render NoAccessPage when the URL slug doesn't resolve to an accessible workspace — except when the slug previously resolved during this layout instance's lifetime. URL and cache are two asynchronous signals: there will always be a short window where the URL still points at the old workspace but the cache has already been invalidated (e.g. just after a delete/leave mutation, or a realtime workspace:deleted event). Rendering NoAccessPage during that window would flash "Workspace not available" with recovery buttons in front of a user who just deleted the workspace themselves — jarring and wrong. useWorkspaceSeen classifies the two cases: - slug was seen before, now gone → user's intent is changing (caller is navigating away); render null, no flash - slug never seen → user is genuinely looking at an inaccessible workspace (stale bookmark, revoked access, link from a former teammate); render NoAccessPage with recovery options NoAccessPage deliberately does not distinguish 404 vs 403 to avoid letting attackers enumerate workspace slugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: redirect zero-workspace users to /new-workspace instead of /onboarding Switches 8 call sites and the CLI: - Web: login, auth callback, landing redirect-if-authenticated - Desktop: routes.tsx IndexRedirect - Shared: dashboard guard, invite page fallback, workspace-tab on delete, realtime sync on workspace loss - CLI: cmd_login.go waitForOnboarding now opens /new-workspace Also adds /new-workspace to navigation store's lastPath exclusion list so it doesn't get persisted as a 'last visited' page. Adds a desktop App.tsx effect that restarts the daemon when workspace count transitions 0 → ≥1, so first-workspace creation triggers immediate daemon pickup rather than waiting up to 30s for the daemon's workspaceSyncLoop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove onboarding flow The 4-step onboarding wizard (workspace → runtime → agent → demo issues) is replaced by: - /new-workspace: a single-page workspace creation form (Phase 3) - NoAccessPage: explicit feedback when a slug doesn't resolve (Phase 4) - daemon zero-workspace bootstrap (Phase 1) so the daemon doesn't crash before the user creates their first workspace - desktop daemon restart on first workspace creation (Phase 5) for instant pickup instead of the 30s workspaceSyncLoop tick Deletions: - packages/views/onboarding/ (OnboardingWizard + 4 step components + tests) - apps/web/app/(auth)/onboarding/page.tsx - apps/desktop/src/renderer/src/components/onboarding-gate.tsx (+test) - OnboardingGate wrapper in desktop-layout.tsx - OnboardingRoute + /onboarding route in desktop routes.tsx - paths.onboarding() builder + /onboarding from GLOBAL_PREFIXES - packages/views/package.json onboarding export - /onboarding from navigation store's EXCLUDED_PREFIXES Retained (intentional): - 'onboarding' in RESERVED_SLUGS (both FE + BE) — kept for FE/BE sync and future-proofing if /onboarding is ever revived Also drops 4 demo issues that onboarding used to create on the new workspace ('Say hello', 'Set up repo', etc.). New workspaces are now fully empty; all list views already render empty-state UI correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: clean stale 'onboarding' references in comments and CLI helpers Batch cleanup of references to the removed onboarding flow: - 13 comment sites mentioning 'onboarding' updated to reflect the new /new-workspace flow or removed where no longer accurate - CLI waitForOnboarding renamed to waitForWorkspaceCreation (function name + docstring); behavior unchanged The 'onboarding' reserved slug entries (frontend + backend) are intentionally retained — see prior commit rationale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(views): extract shared NewWorkspacePage shell The web (/new-workspace) and desktop (NewWorkspaceRoute) pages had identical outer layout — same container, heading, and copy — with only the onSuccess navigation primitive differing. That's exactly the No-Duplication Rule pattern: extract the shared UI, inject the platform-specific behavior. The apps now only own the thin auth guard (web needs it, desktop routes below WorkspaceRouteLayout already handle it) and the onSuccess → navigate call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove rollback compat layer and tighten daemon restart trigger Two cleanup items: 1. Drop localStorage['multica_workspace_id'] double-write in both workspace layouts. That write was added as a rollback safety net for the workspace-slug URL refactor (PR #1138) — the refactor has since landed and stabilized, so the compat shim is no longer needed. Per CLAUDE.md: don't keep compat layers beyond their purpose. 2. Tighten the desktop daemon-restart trigger. The previous ref-based logic fired a restart on any 0→1 workspace-count transition, including account switches (user A logout → user B login). Scope it precisely to 'this session started with zero workspaces and just gained one' using a three-state ref (null=undecided, true=empty-start, false=already-restarted-or-started-nonempty). Account switches are already handled by daemon-manager.ts on token change, so this avoids a redundant restart there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(auth): redirect to /login on logout and unauthenticated workspace visits Two gaps previously left users stuck on blank workspace pages: 1. app-sidebar logout() cleared all state but never moved the URL. The current path is /{workspaceSlug}/... which has no meaning without auth; the workspace layout would then see user=null, render null (via the hasBeenSeen short-circuit), and the user saw a blank page thinking logout didn't work. 2. The workspace layouts (web + desktop) had no !user handling at all. Any path that leaves user=null — token expiration, cross-tab logout, or fresh visit to a workspace URL without a session — resulted in the same blank screen. Fix: - app-sidebar.logout() explicitly push(paths.login()) after authLogout() to cover the primary (user-initiated) logout path. - Both workspace layouts get a defensive useEffect that redirects to /login whenever auth has settled and user is null. Covers token expiration, realtime logout, and any other silent session loss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d12d690c38 |
fix(usage): bucket workspace usage by task_usage.created_at, not enqueue time (#1176)
GetWorkspaceUsageByDay and GetWorkspaceUsageSummary had the same date attribution bug as the runtime endpoint fixed in #1167: they bucketed and filtered on agent_task_queue.created_at (enqueue time), so a task that queued at 23:58 and reported usage at 00:05 was attributed to the prior day, and ?days=N became a rolling now()-N window that clipped the morning of the earliest day returned. Switch both queries to task_usage.created_at (~= task completion time) and snap the since cutoff to start-of-day via DATE_TRUNC, mirroring ListRuntimeUsage. These endpoints have no frontend caller today, but per offline discussion they will back the upcoming workspace-level usage dashboard. Fix preemptively so the dashboard inherits correct numbers. Add a regression test covering both endpoints with the same cross-midnight + earliest-day-cutoff scenarios used for runtime usage. |
||
|
|
a36252ca99 |
refactor(runtime): derive runtime usage from task_usage only (#1167)
* refactor(runtime): derive runtime usage from task_usage only
The daemon used to scan each runtime's local CLI log directory every 5
minutes (Claude Code, Codex, OpenCode, OpenClaw, Hermes) and post daily
aggregates to /api/daemon/runtimes/{id}/usage. Those directories are
shared with the user's own local CLI sessions, so the user's personal
usage was being counted as Daemon-executed usage. Cursor and Gemini had
no scanner at all, so their runtime-level aggregates were always zero.
Switch GetRuntimeUsage to aggregate task_usage (already scoped to
Daemon-executed tasks) via agent_task_queue.runtime_id. Single source of
truth; Cursor/Gemini/Copilot get runtime usage for free; no reliance on
external CLI log formats.
Removes:
- server/internal/daemon/usage/ (all scanners)
- Daemon.usageScanLoop + providerToRuntimeMap
- Client.ReportUsage
- ReportRuntimeUsage handler + POST /api/daemon/runtimes/{id}/usage
- UpsertRuntimeUsage / GetRuntimeUsageSummary queries
- runtime_usage table (migration 046)
Refs: MUL-786
* fix(runtime): bucket daily usage by task_usage.created_at, not enqueue time
ListRuntimeUsage was aggregating by DATE(atq.created_at) and filtering
on atq.created_at. agent_task_queue.created_at is the enqueue timestamp,
which drifts from actual token-production time: a task queued at 23:58
and executed at 00:05 was attributed to yesterday; a task sitting in
the queue overnight was counted on the queue day.
The ?days=N cutoff also became a rolling window (now() - N) instead of
a calendar-day boundary, silently clipping the morning of the earliest
day returned.
Switch bucket + filter to task_usage.created_at (~= task completion /
usage-report time) and snap the since cutoff to start-of-day via
DATE_TRUNC.
Add a regression test covering both scenarios: cross-midnight task
attributes to the day tokens were reported, and the earliest day's
pre-cutoff rows are still included.
|
||
|
|
f0f3cb5c3a |
fix(server): resolve X-Workspace-Slug in middleware-less handlers (#1165)
Problem ------- The v2 workspace URL refactor (#1141) switched the frontend from sending X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was updated to accept the slug and translate it via GetWorkspaceBySlug. But the handler package maintained a PARALLEL resolver (`resolveWorkspaceID` in handler.go) used by endpoints that sit outside the workspace middleware — and that resolver was never updated. It only checked context / ?workspace_id / X-Workspace-ID, never the slug. /api/upload-file is the one production route that hit the broken path: it's user-scoped (not behind workspace middleware) because it also serves avatar uploads (no workspace). Post-refactor requests from the frontend arrived with only X-Workspace-Slug; the handler resolver returned "", the code fell into the "no workspace context" branch, and every file upload since v2 landed in S3 with no corresponding DB attachment row — files orphaned, invisible to the UI. Root cause is structural: two resolvers doing the same job, written independently, diverged silently when one was updated. Fix --- Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest is the new canonical resolver; both the middleware's internal `resolveWorkspaceUUID` (for middleware gating) and the handler-side `(h *Handler).resolveWorkspaceID` (promoted from a package function) now delegate to it. Priority order matches what the middleware has had since v2: context > X-Workspace-Slug header > ?workspace_slug query > X-Workspace-ID header > ?workspace_id query. Impact analysis --------------- 47 call sites of the old `resolveWorkspaceID(r)` are renamed to `h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware, so they hit the context fast path and see zero behavior change. The one caller that actually gains capability is UploadFile — which now correctly recognizes slug requests and creates DB attachment rows. Tests ----- - New table-driven unit test for ResolveWorkspaceIDFromRequest covers all priority levels and the unknown-slug fallback. - Regression tests for UploadFile: once with X-Workspace-Slug only (the broken path), once with X-Workspace-ID only (legacy CLI/daemon compat path). Both assert that a DB attachment row is created. - Full Go test suite passes; typecheck + pnpm test unaffected. Plan ---- See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the full first-principles writeup. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
f8c6dd505f |
fix(security): bind URL issueId to workspace on four issue-scoped daemon.go handlers (#1145)
GetActiveTaskForIssue, CancelTask, ListTasksByIssue, and GetIssueUsage accepted the issueId URL parameter and queried by it without verifying that the issue belonged to the caller's X-Workspace-ID workspace. The RequireWorkspaceMember middleware only proves membership in the header workspace; it does not bind the path-parameter issue to it. A member of workspace A could therefore enumerate tasks, cancel tasks, and read usage metadata for any issue UUID in workspace B. Route every issueId through loadIssueForUser (matching GetIssue and the existing comment/subscriber handlers). For CancelTask additionally verify that the task's IssueID matches the loaded issue — the task must not only belong to the caller's workspace but also to the specific issue named in the URL, and the access check must run before any mutation. Follow-up to MUL-899 / #1112. |
||
|
|
ce52374d5d |
test(daemon): add cross-workspace regression for GetIssueGCCheck (#1143)
Adds TestGetIssueGCCheck_WithDaemonToken_CrossWorkspace alongside the existing TestGetTaskStatus_WithDaemonToken_CrossWorkspace, covering: - daemon token scoped to a different workspace → 404 (matches the "issue not found" status, so no UUID enumeration oracle) - daemon token scoped to the issue's workspace → 200 with status and updated_at fields populated Follow-up to #1121, which fixed the underlying IDOR reported in #1112 but did not ship a regression test. This gates the class of bug at CI so the next handler to forget requireDaemonWorkspaceAccess will be caught before merge. |
||
|
|
441554a520 |
fix(inbox): read workspace ID from request context (#1142)
After the slug-first URL refactor, the frontend sends X-Workspace-Slug and the workspace middleware resolves it into a UUID stored in the request context. The inbox handlers still read X-Workspace-ID directly from the request header, which is now absent, so every inbox query ran with an empty workspace_id and returned zero rows. Switch all six inbox handlers to ctxWorkspaceID(r.Context()), matching the pattern already used by chat / issue / project / autopilot. No frontend changes required — the slug header path was already correct. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
93cf95f799 |
fix(security): enforce workspace access on GetIssueGCCheck (#1121)
The daemon GC check endpoint did not verify the caller's access to the issue's workspace, letting a daemon token or PAT scoped to workspace A read issue status/updated_at for any issue UUID across the instance. Mirror the pattern used by every other handler in daemon.go: look up the issue's workspace and gate on requireDaemonWorkspaceAccess. Closes #1112 Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com> |
||
|
|
fe358feff0 |
Reapply "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139) (#1141)
This reverts commit
|
||
|
|
b30fd98605 |
Revert "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139)
This reverts commit
|
||
|
|
75d12c26c5 |
feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit
|
||
|
|
9b94914bc8 |
Revert "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit
|
||
|
|
59ace95a1e |
feat: workspace URL refactor + slug-first API identity (#1131)
* feat: workspace URL refactor + slug-first API identity
Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).
## Key architectural changes
**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)
**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback
**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers
**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID
## Issues resolved
MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths
C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).
C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.
C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).
C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): wrap shell in WorkspaceSlugProvider from module var
AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).
Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): migrate old tab paths + fix shell slug deadlock
Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.
Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
0427fd8cc7 |
fix(daemon): refresh workspace repos on checkout miss (#1085)
Co-authored-by: black-fe <black-fe@gate.me> |
||
|
|
d930bcaa18 |
feat(server): trigger agent when issue moves out of backlog (#1006)
* feat(server): trigger agent when issue moves out of backlog When a member moves an agent-assigned issue from "backlog" to an active status (e.g. "todo", "in_progress"), enqueue an agent task so the agent starts working. This lets backlog act as a parking lot where issues can be assigned to agents without immediately triggering execution. Applies to both single and batch issue updates. * fix(server): treat backlog as parking lot — no trigger on create/assign Address review feedback: creating or assigning an agent to a backlog issue no longer triggers immediate execution. Only moving out of backlog to an active status triggers the agent, producing exactly one task. - shouldEnqueueAgentTask now gates on backlog status - backlog→active trigger uses isAgentAssigneeReady directly - Added TestBacklogNoTriggerOnCreate test - Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task across the full create→move path (no manual cleanup) * feat(ui): show toast hint when assigning agent to backlog issue Users may not know that backlog issues won't trigger agent execution until moved to an active status. Show an actionable toast with a "Move to Todo" button when: - Assigning an agent to a backlog issue in the detail page - Creating a backlog issue with an agent assignee * feat(ui): add "Don't show again" option to backlog agent toast Users who understand the backlog parking lot behavior can dismiss the hint permanently. Uses localStorage to persist the preference. * feat(ui): replace backlog agent toast with AlertDialog Use a modal dialog instead of a toast notification so users must explicitly acknowledge the hint. The dialog offers three options: - "Move to Todo" — changes status and triggers the agent - "Keep in Backlog" — dismisses without action - "Don't show again" — persists dismissal in localStorage * fix(ui): improve backlog agent dialog * fix(ui): close create dialog behind hint, use checkbox for don't-show-again 1. Create Issue dialog now closes when the backlog agent hint appears, so only the hint dialog is visible (not stacked behind). 2. "Don't show again" is now a checkbox instead of a separate button. When checked, clicking either "Keep in Backlog" or "Move to Todo" persists the preference. * fix(ui): smooth backlog agent hint dialog * fix(test): add useUpdateIssue mock to create-issue test The test mock for @multica/core/issues/mutations was missing the useUpdateIssue export that create-issue.tsx now imports, causing CI failure. |
||
|
|
ce447c7f06 |
feat(agent): add custom CLI arguments support (#986)
* feat(agent): add custom CLI arguments support Allow users to configure custom CLI arguments per agent that get appended to the agent subprocess command at launch time. This enables use cases like specifying different models (--model o3), max turns, or other provider-specific flags without needing separate runtimes. Changes: - Add custom_args JSONB column to agent table (migration 041) - Update API handler to accept/return custom_args in create/update - Pass custom_args through claim endpoint to daemon - Append custom_args to CLI commands for all agent backends - Add ExecOptions.CustomArgs field in agent package - Add Custom Args tab in agent detail UI - Add --custom-args flag to CLI agent create/update commands Closes MUL-802 * fix(agent): filter protocol-critical flags from custom_args Add per-backend filtering of custom_args to prevent users from accidentally overriding flags that the daemon hardcodes for its communication protocol (e.g. --output-format, --input-format, --permission-mode for Claude). This follows the same pattern as custom_env's isBlockedEnvKey: we only block the small, stable set of flags that would break the daemon↔agent protocol — not every possible dangerous flag. Workspace members are trusted for everything else. Each backend defines its own blocked set: - Claude: -p, --output-format, --input-format, --permission-mode - Gemini: -p, --yolo, -o - Codex: --listen - OpenCode: --format - OpenClaw: --local, --json, --session-id, --message - Hermes: none (ACP is positional) Includes unit tests for the filtering logic. * fix(agent): address code review nits for custom_args - Replace module-level `nextArgId` counter with `crypto.randomUUID()` in custom-args-tab.tsx to avoid SSR ID conflicts - Add unit tests for custom args passthrough and blocked-arg filtering in both Claude and Gemini arg builders |
||
|
|
f94b0100cd |
refactor(autopilot): remove broken concurrency policies and fix multiple bugs (#1048)
Remove the concurrency_policy system (skip/queue/replace) — skip had an orphan bug that permanently blocked triggers, queue didn't actually queue, and replace didn't cancel running tasks. Every trigger now simply executes. Bug fixes: - Listener now handles in_review status (was silently ignored) - Issue deletion fails linked autopilot runs before DELETE (prevents orphans) - ComputeNextRun rejects invalid timezones instead of silent UTC fallback - dispatchCreateIssue post-commit failures now properly fail the run Reliability: - Scheduler recovers lost triggers on startup (crash recovery) - New index on autopilot_run(issue_id) for deletion lookups - Migration 043 cleans up historical orphaned/skipped/pending runs |
||
|
|
762e64d469 |
fix(agent): restrict custom_env visibility to owner/admin (#1046)
* fix(agent): restrict custom_env visibility to agent owner and workspace admin Agent environment variables (custom_env) were visible to all workspace members, exposing sensitive tokens. Now only the agent owner and workspace owner/admin can view them — regular members receive the field omitted (null) from API responses, and the frontend hides the Environment tab accordingly. Closes #1018 * fix(agent): show masked env keys to non-authorized users instead of hiding tab Instead of completely hiding the Environment tab for non-owner/non-admin users, show the variable keys with masked values (****) in a read-only view. This lets members see which variables are configured without exposing the actual values. - Backend: mask values with "****" instead of nullifying custom_env - Added custom_env_redacted boolean to API response - Frontend: EnvTab supports readOnly mode with lock icon and muted styling |
||
|
|
53cb01cc91 |
refactor(editor): remove hardcoded CDN domain, unify file card rendering
- Add GET /api/config endpoint exposing cdn_domain from CLOUDFRONT_DOMAIN - Create packages/core/config/ zustand store, fetched at app startup - Extract file card preprocessing to packages/ui/markdown/file-cards.ts with isCdnUrl(url, cdnDomain) using exact hostname match - Add file card support to packages/ui/markdown/Markdown.tsx (was missing) - Remove hardcoded .copilothub.ai hostname check from file-card.tsx - Fix LocalStorage.CdnDomain() to return hostname not full URL - Always run preprocessFileCards regardless of cdnDomain availability (!file syntax works without CDN domain, only legacy matching needs it) - Use useConfigStore hook in common/markdown.tsx for reactive updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d88fe2608e |
feat(autopilot): scheduled/triggered automations for AI agents (#1028)
* feat(autopilot): add scheduled/triggered automation for AI agents Introduce the Autopilot feature — recurring automations that assign work to AI agents on a schedule or manual trigger. Supports two execution modes: create_issue (creates an issue for the agent to work on) and run_only (directly enqueues an agent task without issue pollution). Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService with concurrency policies (skip/queue/replace), HTTP CRUD + trigger endpoints, background cron scheduler (30s tick), event listeners for issue→run and task→run status sync. Frontend: types, API client methods, TanStack Query hooks with optimistic mutations, realtime cache invalidation, list page with create dialog, detail page with trigger management and run history, sidebar nav + routes for both web and desktop apps. * feat(autopilot): improve UX — trigger config, edit dialog, template gallery - Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone - Fix Select components showing UUIDs instead of names (Base UI render function pattern) - Add Edit button on detail page opening a unified edit dialog - Remove project/concurrency/issue-title-template from create/edit (simplify for users) - Add trigger configuration inline during autopilot creation - Add template gallery on empty state (6 step-by-step workflow templates) - Rename "Description" to "Prompt" throughout UI - Inject autopilot run timestamp into issue description for agent date awareness - Treat issue status "in_review" as run completion (fixes skip on next trigger) - Make migration idempotent with IF NOT EXISTS clauses |
||
|
|
60c5848794 |
feat(invitation): dedicated /invite/{id} page for accepting invitations (#1023)
The email CTA now deep-links to /invite/{id} instead of the generic app
URL. If the user isn't logged in, they're redirected to login with a
?next= param that brings them back to the invite page.
Changes:
- Backend: GET /api/invitations/{id} endpoint (enriched with workspace/inviter names)
- Backend: Email template now links to /invite/{invitationId}
- Frontend: Shared InvitePage component (packages/views/invite/)
- Frontend: Web route at (auth)/invite/[id], Desktop route at invite/:id
- Frontend: /invite/ excluded from navigation history persistence
|
||
|
|
1163f684fb |
feat(invitation): send email notification when inviting a user (#1021)
Uses the existing Resend email service to notify invitees. Email includes inviter name, workspace name, and a link to the app. Sent fire-and-forget in a goroutine to avoid blocking the API response. |
||
|
|
ff1d348274 |
feat(security): invitation acceptance flow for workspace members (#1019)
* feat(security): replace instant member-add with invitation acceptance flow Users invited to a workspace must now explicitly accept the invitation before becoming a member. This fixes the security vulnerability where knowing someone's email was enough to auto-register their runtime to your workspace. Changes: - Add workspace_invitation table with pending/accepted/declined/expired states - Replace CreateMember with CreateInvitation (same endpoint, new behavior) - Add accept/decline/revoke/list invitation API endpoints - Add invitation WS events for real-time notification - Frontend: invitation accept/decline UI in workspace switcher - Frontend: pending invitations section in members settings tab * fix(invitation): address PR review nits - Fix invitation:revoked listener to send event to invitee user (was no-op) - Remove duplicate queryClient2 in app-sidebar.tsx, reuse existing queryClient - Add expires_at > now() filter to ListPendingInvitationsByWorkspace query |
||
|
|
b4b69f89f6 |
fix(server): allow members to create and manage their own skills (#1017)
Remove admin/owner-only restriction from skill creation and import routes. Add canManageSkill helper that lets skill creators manage their own skills, matching the existing canManageAgent pattern for agents. |
||
|
|
40aa23a528 |
feat(desktop): daemon management panel with sidebar status bar (#952)
* feat(desktop): add daemon management panel with sidebar status bar Integrate multica daemon lifecycle management into the desktop app so users can start/stop/restart the daemon and view live logs without leaving the UI. Session tokens are automatically synced to the CLI config file, making daemon authentication transparent. - daemon-manager.ts: Electron main process module for daemon lifecycle (health polling, start/stop via CLI, token sync, log tail) - Preload bridge: new daemonAPI with IPC for all daemon operations - Sidebar bottomSlot: persistent daemon status indicator in sidebar footer (desktop-only, injected via AppSidebar slot) - Daemon panel Sheet: right-side drawer with status details, controls, and real-time log viewer with auto-scroll and level coloring - Token sync: on login and app startup, JWT is written to ~/.multica/config.json so daemon can authenticate seamlessly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings P1: Runtimes page Local Daemon card - Add topSlot prop to shared RuntimesPage for platform injection - DaemonRuntimeCard shows status, agents, uptime with Start/Stop/ Restart/Logs buttons (desktop-only, injected via slot) P2: Auto-start and auto-stop - Daemon auto-starts on app launch when user is authenticated (controlled by autoStart preference, default: true) - Daemon auto-stops on app quit (controlled by autoStop preference, default: false — daemon keeps running in background by default) - Preferences persisted to ~/.multica/desktop_prefs.json P2: Daemon settings tab - New "Daemon" tab in Settings > My Account section (desktop-only) - Toggle auto-start and auto-stop behavior - CLI installation status check with link to install guide - SettingsPage gains extraAccountTabs prop for platform injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): address PR review feedback on daemon management Must-fix: - before-quit handler now calls event.preventDefault(), awaits stopDaemon(), then re-calls app.quit() so the daemon actually stops before the app exits - Add concurrency guard (operationInProgress lock) in daemon-manager to reject overlapping start/stop/restart IPC calls - Extract shared types (DaemonState, DaemonStatus, DaemonPrefs), constants (STATE_COLORS, STATE_LABELS), and formatUptime to apps/desktop/src/shared/daemon-types.ts — all renderer components now import from this single source Should-fix: - Log viewer uses monotonic counter (LogEntry.id) instead of array index as React key, preventing full re-renders on overflow - All start/stop/restart handlers now show toast.error() with the error message when the operation fails - startLogTail retries up to 5 times with 2s delay when the log file doesn't exist yet (handles first-run case) Minor: - Cache findCliBinary() result after first successful lookup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(logger): suppress ANSI color codes when stderr is not a TTY Detect whether stderr is connected to a terminal and set tint's NoColor option accordingly. Previously daemon.log files contained raw escape sequences like \033[2m and \033[92m which made them unreadable in the Desktop log viewer and any non-TTY sink (docker logs, systemd, etc). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(daemon): runtime watch/unwatch HTTP endpoints and denylist Add GET/POST/DELETE /watch handlers on the daemon's health port so clients (notably Desktop) can add or remove watched workspaces at runtime without restarting the daemon or editing config.json. Each handler updates in-memory state under d.mu and persists back to ~/.multica/profiles/<name>/config.json for survival across restarts. - CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist. syncWorkspacesFromAPI skips entries in the denylist so a manual unwatch isn't silently revived 30s later by the periodic sync. - loadWatchedWorkspaces tolerates an empty config and returns nil instead of erroring out, because Desktop starts daemons with a fresh profile and relies on the sync loop / watch endpoint to populate the list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): bundled CLI, per-backend profile, and watch UI Make the Desktop app self-sufficient: it bundles its own multica binary, manages its own daemon profile keyed by the backend URL, and authenticates that daemon with a long-lived PAT it mints on first login. The daemon panel gains a checkbox list of watched workspaces and surfaces the active profile + server URL. CLI bootstrap - scripts/bundle-cli.mjs copies server/bin/multica into apps/desktop/resources/bin/ before electron-vite dev and electron-builder package. asarUnpack: resources/** already covers this path, so the binary ships with the .app in prod. - main/cli-bootstrap.ts adds an ensureManagedCli() fallback that downloads the latest release from GitHub when no bundled binary exists (first launch on a machine without developer tooling). - daemon-manager.resolveCliBinary prefers bundled > managed > download > PATH, so local iteration uses the freshly built binary. Daemon profile - resolveActiveProfile now derives a desktop-<host> profile name from the target API URL and creates its config.json on demand. Never reads or writes the user's hand-configured CLI profiles, avoiding the "Desktop polluted my default profile" class of bug. - syncToken detects a JWT input and exchanges it for a PAT via POST /api/tokens; caches the resulting mul_* token in the profile config so subsequent launches skip the round-trip. - startDaemon / stopDaemon / log tail all operate on the resolved profile; renderer sets the target URL via a new daemon:set-target-api-url IPC. Workspace watching - daemon-manager exposes daemon:list-watched / daemon:watch-workspace / daemon:unwatch-workspace IPCs backed by the daemon's new /watch endpoints. - App.tsx reconciles the user's workspace list against the daemon's watched set whenever TanStack Query updates it — new workspaces are registered instantly instead of waiting for the daemon's 30s sync, and removed workspaces are unwatched. - daemon-panel gains a "Watched Workspaces" section with per-workspace checkboxes that call watch/unwatch directly. Opt-outs persist in the profile's unwatched_workspaces denylist. Lifecycle states + UI - DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli` state. Panel shows Profile / Server info rows and a "Setting up…" blurb during first-run CLI download; failure surfaces a Retry button. - Status bar renders a spinner during installation and hides the Start button until setup finishes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): register /onboarding route The create-workspace modal navigates to /onboarding on success, but the Desktop router only had flat routes (issues, projects, runtimes, etc.) — resulting in an "Unexpected Application Error! 404 Not Found" page after creating a new workspace. Mirror the web app's wiring: render OnboardingWizard with onComplete pushing to /issues, via the shared navigation adapter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(desktop): remove sidebar daemon status bar Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard at the top of the Runtimes page, which already shows the same info plus full Start/Stop/Restart controls and the Logs entry point. A single canonical place avoids fragmenting daemon status across the UI. Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop was the only consumer, Web never needed it, so keeping it would be dead scaffolding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): daemon panel layout and close button - Logs section now fills the remaining vertical space down to the sheet bottom instead of being capped at h-64, which left a huge empty area below it. Top section (status, actions, watched list) keeps natural height as shrink-0; the watched list gets its own max-h-48 scroll so a long list can't push Logs off screen. - Replace the Sheet's built-in close button with an explicit <button> wired directly to onOpenChange(false). The Base UI Dialog.Close wrapped in Button via the render prop wasn't firing on click in this panel; going straight through the controlled state guarantees it responds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): make daemon panel clickable inside Electron drag region The sheet opens at the top of the window, which visually overlaps the TabBar's -webkit-app-region: drag zone. Even though the sheet portals to document.body, Chromium computes drag regions over the final composited pixels, so the sheet inherited "drag" and swallowed the mouseup of every click (mousedown fired but click never resolved) — including the X close button. Mark the entire SheetContent popup with -webkit-app-region: no-drag to subtract it from the drag region. This also fixes future buttons / checkboxes inside the sheet that would have hit the same issue. While here, move the close button into the SheetHeader as a flex sibling of SheetTitle instead of an absolutely positioned overlay — simpler layout and avoids any stacking-context weirdness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): clickable daemon runtime card row The whole Local Daemon row now opens the sheet panel — icon, title, and status line are all part of one click target. This replaces the standalone "Logs" button, which was redundant now that clicking anywhere on the row does the same thing. The right-side action cluster (Start / Stop / Restart) wraps its onClick in stopPropagation so pressing those buttons doesn't bubble up and open the panel. Keyboard access: Enter / Space on the focused row opens the panel, with a focus-visible background for feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(runtimes): mark Desktop-launched daemons as managed When the Multica Desktop app spawns the CLI it ships with, the resulting daemon shares its binary with the Electron bundle — Desktop is responsible for updating that binary on every release. Letting the daemon self-update would just get clobbered on the next Desktop launch and could brick the embedded binary mid-update. Propagate a "launched_by" signal end-to-end so the UI can hide the CLI self-update affordance (and the daemon refuses updates as a second line of defense): - Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop. - daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot. - registerRuntimesForWorkspace includes launched_by in the request body. - Server DaemonRegister folds launched_by into runtime.metadata (JSONB — no migration needed). - handleUpdate returns a "failed" status with an explanatory message when LaunchedBy == "desktop", so even a bypass API call can't trigger the self-update path. - RuntimeDetail extracts metadata.launched_by and passes it to UpdateSection, which swaps the Latest / → available / Update button cluster for a muted "Managed by Desktop" label. CLI-only users (brew install, direct tarball) keep the exact same behavior — the env var is empty, the UI shows the update button, the daemon still self-updates on request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): harden daemon manager from PR review - syncToken now takes userId and mints a fresh PAT on user switch, restarting a running daemon so it picks up the new credentials. A .desktop-user-id sidecar in each profile records the owner so a previous user's cached PAT can't be reused on the next login. - App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken() and daemonAPI.stop() so the cached PAT and live daemon don't outlive the session. - startLogTail replaced with a cross-platform watchFile implementation (initial 32 KB window + poll for new bytes, handles truncation). spawn("tail") was broken on Windows. - writeProfileConfig now serializes through a promise chain to prevent concurrent writes from corrupting config.json. - startDaemon keeps the "starting" state until pollOnce confirms /health, avoiding a running → stopped flash when the Go daemon isn't yet listening after the supervisor returns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): verify downloaded CLI against checksums.txt Download goreleaser's checksums.txt alongside the release archive, parse the sha256 lookup, stream the archive through createHash, and refuse to install on mismatch or missing entry. Closes the supply- chain gap where auto-install would execute an unverified binary on first launch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(desktop): lint and style cleanups from PR review - eslint.config.mjs: add scripts/**/*.{mjs,js} override with globals.node so bundle-cli.mjs lints clean (was erroring on undefined process/console). - daemon-panel.tsx: log level classes now use semantic tokens (text-info, text-warning, text-destructive) instead of hardcoded Tailwind colors; escape the apostrophe in the retry copy. - daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon- types instead of redefining it. - runtimes-page.tsx: fix indentation inside the new topSlot wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: yushen <ldnvnbl@gmail.com> |
||
|
|
a744cd4f45 |
feat(chat): redesign state, header, and unread tracking
State management - Pending task / live timeline are now Query-cache single source; Zustand mirror removed (fixes duplicate assistant render caused by the invalidate→refetch race window) - WS subscriptions moved from ChatWindow to global useRealtimeSync so pending state survives minimize and refresh - New GET /chat/sessions/:id/pending-task to recover live state on mount - Drafts persisted per-session (was per-workspace) Unread tracking - Migration 040: chat_session.unread_since (event-driven; old chats stay clean — no mass backfill) - POST /chat/sessions/:id/read clears unread; broadcasts chat:session_read so other devices sync - New GET /chat/pending-tasks aggregate for the FAB - ChatFab: brand-color impulse animation while running, brand-dot badge of unread session count - ChatWindow auto-marks read when user is viewing the session Header redesign - Two independent dropdowns: agent (avatar + name + My/Others grouping) at the input bottom-left; session (title + agent avatar) in the header - ⊕ new-chat button replaces the old + and history buttons - Session dropdown lists all sessions across agents with avatars - Empty state: 3 clickable starter prompts that send immediately - Mention link renderer falls through to default span on null — fixes @member/@agent/@all silently disappearing app-wide - User messages render through Markdown - Enter submits in chat input only (with IME guard + codeBlock skip); bubble menu hidden in chat Misc - Partial index on agent_task_queue for fast pending-task lookup - 2 new storage keys added to clearWorkspaceStorage - useMarkChatSessionRead has onError rollback - chat.* namespace logs across store, mutations, components, realtime Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
bf71802451 |
fix(server): trigger agent on reply in thread where agent already participated (#981)
When a member replies in a member-started thread without @mentioning the assigned agent, the on_comment trigger was suppressed — even if the agent had already replied in that thread. This meant the common flow of "member posts → agent replies → member follows up" would not re-trigger the agent on the follow-up. Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread so that agent participation in a thread is treated as an ongoing conversation. |
||
|
|
0a998d1cef |
Merge pull request #846 from multica-ai/agent/j/feb218fd
feat(agent): support custom environment variables for router/proxy mode |
||
|
|
9dfe119f47 |
fix(daemon): use runtime's owner_id for agent migration on upgrade (#941)
* fix(daemon): prevent duplicate runtime registration on profile switch The daemon_id included a profile name suffix (e.g. "hostname-staging"), so switching profiles created a new daemon_id that bypassed the UPSERT dedup constraint, leaving orphaned runtime records in the database. Three changes: - Remove profile suffix from daemon_id — use stable hostname only. The unique constraint (workspace_id, daemon_id, provider) already prevents collisions within the same workspace. - Auto-migrate agents from old offline runtimes to the newly registered runtime during DaemonRegister (same workspace/provider/owner). - Add TTL-based GC in the runtime sweeper to delete offline runtimes with no active agents after 7 days. Closes MUL-695 * fix(daemon): address code review issues on PR #906 1. Move gcRuntimes() to the main sweep loop — previously it was inside sweepStaleRuntimes() after an early return, so it only ran when new runtimes were marked stale. Now it runs every sweep cycle independently. 2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent reference (not just active ones). The FK agent.runtime_id is ON DELETE RESTRICT, so archived agents also block deletion. 3. Scope MigrateAgentsToRuntime to the same machine by matching daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine agent migration when the same user has multiple devices. * fix(daemon): use runtime's owner_id for agent migration, not caller's The migration was gated on ownerID.Valid which is only true for PAT/JWT registrations. Daemon token registrations (the common case for background daemon restarts) had ownerID as zero, skipping migration entirely. Fix: use registered.OwnerID (preserved via COALESCE on upsert) instead of the caller's ownerID. This ensures migration runs even when the daemon re-registers via daemon token after an upgrade. |
||
|
|
5e74c411dc |
fix(server): cancel active tasks when issue status changes to cancelled (#940)
When a user cancels an issue, active agent tasks now get cancelled automatically. Previously, task cancellation only triggered on assignee changes — the cancelled status was incorrectly treated like any other agent-managed status transition. Closes #926 |
||
|
|
7c7d7feed3 |
fix(storage): scope S3 upload keys by workspace (#936)
* fix(storage): scope S3 upload keys by workspace
Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): scope user uploads under users/{user_id}/ prefix
Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): fix LocalStorage for nested key paths
- Add MkdirAll before WriteFile to create intermediate directories
for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
of stripping to just the filename
- Update tests to match new behavior
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): validate ownership before writing to storage
Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): restore workspace membership check before upload
The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.
Also restores the comment explaining the 200-on-DB-error behavior.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
8c0708bb5d |
fix(server): validate workspace membership for subscriptions and uploads (#935)
* fix(server): validate workspace membership for subscription targets and file uploads Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload missing workspace member validation) from the security audit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(server): add negative tests for cross-workspace subscription and upload Address PR review feedback: - Add tests verifying cross-workspace user_id is rejected with 403 on subscribe and unsubscribe - Add test verifying upload with foreign workspace_id is rejected with 403 - Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject unknown user types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |