mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3cdb1759c | ||
|
|
15b430c8d5 |
@@ -120,9 +120,24 @@ export interface AgentTask {
|
||||
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
|
||||
/**
|
||||
* Local working directory pinned for this task by the daemon. Empty until
|
||||
* the daemon reports a work_dir (typically once execution starts).
|
||||
* the daemon reports a work_dir (typically once execution starts). This is
|
||||
* the canonical absolute path the agent runs in; UI surfaces should prefer
|
||||
* `relative_work_dir` to avoid leaking the user's home directory.
|
||||
*/
|
||||
work_dir?: string;
|
||||
/**
|
||||
* Privacy-safe display form of `work_dir`, derived on the server. For
|
||||
* standard tasks the daemon's workspaces root has been stripped off
|
||||
* (`<wsUUID>/<taskShort>/workdir`); for local_directory tasks where the
|
||||
* path lives outside that layout, the server strips recognised home
|
||||
* prefixes (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`)
|
||||
* and otherwise falls back to the basename so neither the home directory
|
||||
* nor the username leak into the UI. Older backends omit the field —
|
||||
* render it conditionally and never render `work_dir` raw (not even in
|
||||
* a tooltip / `title` / `aria-label`, since the goal is that screen
|
||||
* shares and screenshots also stay safe).
|
||||
*/
|
||||
relative_work_dir?: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Cloud,
|
||||
Cpu,
|
||||
Filter,
|
||||
FolderTree,
|
||||
ArrowDownNarrowWide,
|
||||
ArrowUpNarrowWide,
|
||||
} from "lucide-react";
|
||||
@@ -473,6 +474,19 @@ export function AgentTranscriptDialog({
|
||||
: t(($) => $.transcript.events, { count: items.length })}
|
||||
</MetadataChip>
|
||||
|
||||
{/* Working directory — server-derived display path. Falls back to
|
||||
nothing when older backends omit the field rather than rendering
|
||||
`work_dir` raw and leaking the user's home directory. The
|
||||
absolute `task.work_dir` deliberately never reaches the DOM
|
||||
(no title/aria/data attribute), since the goal of this chip is
|
||||
that recordings, screen shares, and screenshots never expose
|
||||
$HOME or the username. */}
|
||||
{task.relative_work_dir && (
|
||||
<MetadataChip icon={<FolderTree className="h-3 w-3" />}>
|
||||
<span className="font-mono">{task.relative_work_dir}</span>
|
||||
</MetadataChip>
|
||||
)}
|
||||
|
||||
{/* Created time */}
|
||||
{task.created_at && (
|
||||
<MetadataChip>
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -184,6 +186,17 @@ type AgentTaskResponse struct {
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
WorkDir string `json:"work_dir,omitempty"` // local working directory pinned for this task; populated once the daemon reports it
|
||||
// RelativeWorkDir is a privacy-safe display form of WorkDir intended for
|
||||
// the UI. For standard tasks it strips the daemon's workspaces root so
|
||||
// the user sees `<wsUUID>/<taskShort>/workdir`; for local_directory
|
||||
// tasks the absolute path lives outside the envRoot layout, so we strip
|
||||
// recognised home-directory prefixes (`/Users/<name>/`, `/home/<name>/`,
|
||||
// `<drive>:/Users/<name>/`) and otherwise fall back to the basename so
|
||||
// the field never carries the user's home dir or account name. Empty
|
||||
// when WorkDir is empty, or when stripping leaves nothing. See
|
||||
// relativeWorkDir() for the full rules. Older clients can still read
|
||||
// WorkDir directly; newer UIs should prefer RelativeWorkDir.
|
||||
RelativeWorkDir string `json:"relative_work_dir,omitempty"`
|
||||
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
|
||||
@@ -249,7 +262,13 @@ type TaskAgentData struct {
|
||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
// taskToResponse maps a queue row to its wire shape. workspaceID is threaded
|
||||
// in because the row itself doesn't carry one (workspace lives on the agent
|
||||
// / issue / chat session) — we ask the caller to resolve it once and pass it
|
||||
// down. It populates WorkspaceID and powers the privacy-safe RelativeWorkDir
|
||||
// derivation; pass "" only on daemon-facing paths that genuinely don't have
|
||||
// it, in which case RelativeWorkDir falls back to the existing WorkDir.
|
||||
func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
|
||||
var result any
|
||||
if t.Result != nil {
|
||||
json.Unmarshal(t.Result, &result)
|
||||
@@ -267,6 +286,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
RuntimeID: uuidToString(t.RuntimeID),
|
||||
IssueID: uuidToString(t.IssueID),
|
||||
WorkspaceID: workspaceID,
|
||||
Status: t.Status,
|
||||
Priority: t.Priority,
|
||||
DispatchedAt: timestampToPtr(t.DispatchedAt),
|
||||
@@ -282,6 +302,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
|
||||
TriggerSummary: textToPtr(t.TriggerSummary),
|
||||
WorkDir: workDir,
|
||||
RelativeWorkDir: relativeWorkDir(workDir, workspaceID, uuidToString(t.ID)),
|
||||
// Surface task source so the UI can distinguish issue-linked tasks
|
||||
// from chat-spawned or autopilot-spawned ones; all three may arrive
|
||||
// with issue_id = "" once a task has no linked issue.
|
||||
@@ -291,6 +312,105 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// relativeWorkDir produces a privacy-safe display form of the daemon-reported
|
||||
// absolute work_dir. The contract: the returned string must never contain
|
||||
// the user's home directory prefix or their account name. The chip is
|
||||
// rendered in transcripts that frequently end up in screen shares,
|
||||
// screenshots, and recordings, so this function is the only guard.
|
||||
//
|
||||
// - For standard tasks (work_dir laid out as `<workspacesRoot>/<wsUUID>/
|
||||
// <taskShort>/workdir` by execenv.Prepare), it strips everything up to and
|
||||
// including the workspaces root, returning `<wsUUID>/<taskShort>/workdir`.
|
||||
// - For local_directory tasks the absolute path lives outside the envRoot
|
||||
// layout. We try to recognise common home-directory prefixes
|
||||
// (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`) and strip
|
||||
// them, returning the remainder (e.g. `repos/foo`). When the prefix
|
||||
// can't be recognised — unusual home layouts, network mounts, paths
|
||||
// under `/opt`, `/srv`, etc. — we fall back to the basename so we never
|
||||
// accidentally render a path component that happens to be a username.
|
||||
//
|
||||
// Returns empty when work_dir is empty, or when stripping leaves nothing
|
||||
// (i.e. work_dir was exactly the user's home — rendering nothing is
|
||||
// preferable to a chip that says `<name>`). shortTaskID() must stay in
|
||||
// lock-step with server/internal/daemon/execenv/git.go:shortID — both
|
||||
// consume the same task UUID; if that helper changes, this one must too
|
||||
// or the envRoot match silently degrades to the local_directory fallback.
|
||||
func relativeWorkDir(workDir, workspaceID, taskID string) string {
|
||||
if workDir == "" {
|
||||
return ""
|
||||
}
|
||||
// Normalize Windows separators so the rest of the function only
|
||||
// reasons about forward slashes.
|
||||
normalized := strings.ReplaceAll(workDir, "\\", "/")
|
||||
|
||||
if workspaceID != "" && taskID != "" {
|
||||
envRootSuffix := workspaceID + "/" + shortTaskID(taskID)
|
||||
if idx := strings.Index(normalized, envRootSuffix); idx >= 0 {
|
||||
return normalized[idx:]
|
||||
}
|
||||
}
|
||||
|
||||
if stripped, ok := stripHomePrefix(normalized); ok {
|
||||
return stripped
|
||||
}
|
||||
|
||||
return basename(normalized)
|
||||
}
|
||||
|
||||
// shortTaskID mirrors execenv.shortID — first 8 hex chars of the UUID
|
||||
// with dashes stripped. Kept inline here so the agent handler has zero
|
||||
// imports from the daemon package (which would create an unwanted cycle
|
||||
// between handler and daemon).
|
||||
func shortTaskID(uuid string) string {
|
||||
s := strings.ReplaceAll(uuid, "-", "")
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// homeDirPattern matches the well-known per-user home layouts on macOS,
|
||||
// Linux, and Windows after backslash normalization:
|
||||
//
|
||||
// /Users/<name>[/<rest>]
|
||||
// /home/<name>[/<rest>]
|
||||
// <drive>:/Users/<name>[/<rest>]
|
||||
//
|
||||
// Case-insensitive because macOS and Windows are case-insensitive at the
|
||||
// filesystem layer; matching `/users/...` the same as `/Users/...` keeps
|
||||
// the strip robust against unusual casings seen on shared drives.
|
||||
// Capture group 1 is the optional remainder after the username segment.
|
||||
var homeDirPattern = regexp.MustCompile(`(?i)^(?:[A-Za-z]:)?/(?:Users|home)/[^/]+(?:/(.*))?$`)
|
||||
|
||||
// stripHomePrefix recognises common home-directory layouts and returns
|
||||
// the path remainder after the username segment. Returns (remainder, true)
|
||||
// when a known home prefix matched. The remainder may be the empty string
|
||||
// (work_dir was exactly the home directory) — the caller treats that as
|
||||
// "nothing safe to display".
|
||||
func stripHomePrefix(p string) (string, bool) {
|
||||
m := homeDirPattern.FindStringSubmatch(p)
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
return m[1], true
|
||||
}
|
||||
|
||||
// basename returns the last non-empty segment of a forward-slash path.
|
||||
// Used as the ultimate privacy-safe fallback when we can't otherwise
|
||||
// recognise the path: a single segment can never expose the home prefix,
|
||||
// and the leaf is almost always the most useful piece of context anyway
|
||||
// (typically the repo directory name for local_directory tasks).
|
||||
func basename(p string) string {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.LastIndex(p, "/"); idx >= 0 {
|
||||
return p[idx+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// computeTaskKind picks the source-discriminator string the activity UI uses
|
||||
// to choose how to render a task row. Computed from the existing FK shape so
|
||||
// no extra DB lookup is needed: chat / autopilot / comment-on-issue (any
|
||||
@@ -1120,7 +1240,7 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
resp[i] = taskToResponse(t, workspaceID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -1257,7 +1377,7 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
|
||||
if _, ok := allowed[uuidToString(t.AgentID)]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, taskToResponse(t))
|
||||
resp = append(resp, taskToResponse(t, workspaceID))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
206
server/internal/handler/agent_work_dir_test.go
Normal file
206
server/internal/handler/agent_work_dir_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
||||
)
|
||||
|
||||
// TestRelativeWorkDir covers the privacy-safe display derivation that
|
||||
// agent-transcript dialogs render in the work_dir chip. Two regression
|
||||
// concerns drive the table:
|
||||
//
|
||||
// 1. Standard tasks must strip the daemon's workspaces root so the chip
|
||||
// doesn't expose the user's home directory or username (the bug in
|
||||
// PR #3379 that this fix replaces).
|
||||
// 2. local_directory tasks have a work_dir outside the envRoot layout —
|
||||
// we must NOT leak `/Users/<name>/...`, `/home/<name>/...`, or
|
||||
// `<drive>:/Users/<name>/...` even on shallow paths like
|
||||
// `/Users/alice/foo`. The function strips recognised home prefixes
|
||||
// and otherwise falls back to the basename, which can never carry a
|
||||
// username segment.
|
||||
func TestRelativeWorkDir(t *testing.T) {
|
||||
const (
|
||||
wsID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
||||
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workDir string
|
||||
wsID string
|
||||
taskID string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty work_dir returns empty",
|
||||
workDir: "",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "standard envRoot path strips workspaces root",
|
||||
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: wsID + "/5c57b65b/workdir",
|
||||
},
|
||||
{
|
||||
name: "standard envRoot path without trailing workdir",
|
||||
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: wsID + "/5c57b65b",
|
||||
},
|
||||
{
|
||||
name: "local_directory path under /Users home is stripped",
|
||||
workDir: "/Users/df007df/repos/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "repos/foo",
|
||||
},
|
||||
{
|
||||
name: "local_directory deep path under home keeps full remainder",
|
||||
workDir: "/Users/df007df/code/work/projects/multica/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "code/work/projects/multica/foo",
|
||||
},
|
||||
{
|
||||
name: "shallow /Users home path strips username segment",
|
||||
workDir: "/Users/alice/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "foo",
|
||||
},
|
||||
{
|
||||
name: "shallow Linux /home path strips username segment",
|
||||
workDir: "/home/alice/project",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "project",
|
||||
},
|
||||
{
|
||||
name: "shallow Windows /Users path strips username segment",
|
||||
workDir: `C:\Users\alice\foo`,
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "foo",
|
||||
},
|
||||
{
|
||||
name: "exact home directory returns empty (would only render username)",
|
||||
workDir: "/Users/alice",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "exact home directory with trailing slash returns empty",
|
||||
workDir: "/Users/alice/",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Windows local_directory path under home strips username",
|
||||
workDir: `C:\Users\alice\repos\foo`,
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "repos/foo",
|
||||
},
|
||||
{
|
||||
name: "non-home local path falls back to basename only",
|
||||
workDir: "/opt/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "foo",
|
||||
},
|
||||
{
|
||||
name: "non-home deep local path falls back to basename only",
|
||||
workDir: "/srv/git/repo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "repo",
|
||||
},
|
||||
{
|
||||
name: "single-segment local path returns the segment",
|
||||
workDir: "/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "foo",
|
||||
},
|
||||
{
|
||||
name: "Windows backslash separators are normalized",
|
||||
workDir: `C:\Users\alice\multica_workspaces\` + wsID + `\5c57b65b\workdir`,
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: wsID + "/5c57b65b/workdir",
|
||||
},
|
||||
{
|
||||
name: "missing workspace_id under home strips home prefix instead of envRoot",
|
||||
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
||||
wsID: "",
|
||||
taskID: taskID,
|
||||
expected: "multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
||||
},
|
||||
{
|
||||
name: "missing task_id under home strips home prefix instead of envRoot",
|
||||
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
||||
wsID: wsID,
|
||||
taskID: "",
|
||||
expected: "multica_workspaces/" + wsID + "/5c57b65b/workdir",
|
||||
},
|
||||
{
|
||||
name: "trailing slash on envRoot path is preserved in returned suffix",
|
||||
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir/",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: wsID + "/5c57b65b/workdir/",
|
||||
},
|
||||
{
|
||||
name: "wsID prefix appearing elsewhere falls back to basename when not under home",
|
||||
workDir: "/var/" + wsID + "/something/else",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "else",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive /users matches the same as /Users",
|
||||
workDir: "/users/alice/repos/foo",
|
||||
wsID: wsID,
|
||||
taskID: taskID,
|
||||
expected: "repos/foo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := relativeWorkDir(tc.workDir, tc.wsID, tc.taskID)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("relativeWorkDir(%q, %q, %q) = %q, want %q",
|
||||
tc.workDir, tc.wsID, tc.taskID, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortTaskIDMatchesDaemon pins shortTaskID() to execenv.PredictRootDir's
|
||||
// path layout. Both helpers consume the same task UUID; if the daemon's
|
||||
// shortID logic drifts, this test trips loudly instead of letting the UI
|
||||
// silently fall back to the "tail two segments" branch. Without this guard,
|
||||
// a daemon-side change to, say, a 12-char prefix would not break a build —
|
||||
// it would just quietly degrade every standard-task work_dir chip into the
|
||||
// local_directory fallback.
|
||||
func TestShortTaskIDMatchesDaemon(t *testing.T) {
|
||||
const (
|
||||
workspacesRoot = "/tmp/workspaces"
|
||||
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
|
||||
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
|
||||
)
|
||||
daemonRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
|
||||
expected := workspacesRoot + "/" + workspaceID + "/" + shortTaskID(taskID)
|
||||
if daemonRoot != expected {
|
||||
t.Fatalf("daemon PredictRootDir = %q, handler-side reconstruction = %q — shortTaskID is out of sync with execenv.shortID", daemonRoot, expected)
|
||||
}
|
||||
}
|
||||
@@ -736,7 +736,7 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*cancelled))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*cancelled, workspaceID))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -93,9 +93,21 @@ func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
// requireDaemonTaskAccess looks up a task and verifies the caller owns its workspace.
|
||||
func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, bool) {
|
||||
task, _, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||||
return task, ok
|
||||
}
|
||||
|
||||
// requireDaemonTaskAccessWithWorkspace is the workspace-aware variant of
|
||||
// requireDaemonTaskAccess. It returns the resolved workspace ID alongside
|
||||
// the task row so callers that need to forward workspace_id into
|
||||
// taskToResponse (powering RelativeWorkDir) don't have to repeat the
|
||||
// ResolveTaskWorkspaceID lookup. The two helpers share their entire
|
||||
// implementation; the simpler one is preserved for ergonomic call sites
|
||||
// that genuinely don't need workspace_id.
|
||||
func (h *Handler) requireDaemonTaskAccessWithWorkspace(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, string, bool) {
|
||||
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
|
||||
if !ok {
|
||||
return db.AgentTaskQueue{}, false
|
||||
return db.AgentTaskQueue{}, "", false
|
||||
}
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||||
if err != nil {
|
||||
@@ -104,23 +116,23 @@ func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request
|
||||
// error must not be reported as a deletion.
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return db.AgentTaskQueue{}, false
|
||||
return db.AgentTaskQueue{}, "", false
|
||||
}
|
||||
slog.Warn("get agent task failed", "task_id", taskID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load task")
|
||||
return db.AgentTaskQueue{}, false
|
||||
return db.AgentTaskQueue{}, "", false
|
||||
}
|
||||
|
||||
wsID := h.TaskService.ResolveTaskWorkspaceID(r.Context(), task)
|
||||
if wsID == "" {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return db.AgentTaskQueue{}, false
|
||||
return db.AgentTaskQueue{}, "", false
|
||||
}
|
||||
|
||||
if !h.requireDaemonWorkspaceAccess(w, r, wsID) {
|
||||
return db.AgentTaskQueue{}, false
|
||||
return db.AgentTaskQueue{}, "", false
|
||||
}
|
||||
return task, true
|
||||
return task, wsID, true
|
||||
}
|
||||
|
||||
// verifyDaemonWorkspaceAccess checks workspace access without writing an HTTP error.
|
||||
@@ -1095,7 +1107,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
buildStart = time.Now()
|
||||
|
||||
// Build response with fresh agent data (name + skills + custom_env + custom_args).
|
||||
resp := taskToResponse(*task)
|
||||
resp := taskToResponse(*task, runtimeWorkspaceID)
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
|
||||
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
|
||||
var customEnv map[string]string
|
||||
@@ -1601,9 +1613,11 @@ func (h *Handler) ListPendingTasksByRuntime(w http.ResponseWriter, r *http.Reque
|
||||
runtimeID := chi.URLParam(r, "runtimeId")
|
||||
|
||||
// Verify the caller owns this runtime's workspace.
|
||||
if _, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID); !ok {
|
||||
runtime, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := uuidToString(runtime.WorkspaceID)
|
||||
|
||||
tasks, err := h.Queries.ListPendingTasksByRuntime(r.Context(), parseUUID(runtimeID))
|
||||
if err != nil {
|
||||
@@ -1613,7 +1627,7 @@ func (h *Handler) ListPendingTasksByRuntime(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
resp[i] = taskToResponse(t, workspaceID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -1628,7 +1642,8 @@ func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
// Verify the caller owns this task's workspace.
|
||||
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
||||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1640,7 +1655,7 @@ func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
slog.Info("task started", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||||
}
|
||||
|
||||
// TaskWaitLocalDirectoryRequest is the body the daemon POSTs when it parks
|
||||
@@ -1660,7 +1675,8 @@ type TaskWaitLocalDirectoryRequest struct {
|
||||
func (h *Handler) MarkTaskWaitingLocalDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
||||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1679,7 +1695,7 @@ func (h *Handler) MarkTaskWaitingLocalDirectory(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||||
}
|
||||
|
||||
// ReportTaskProgress broadcasts a progress update.
|
||||
@@ -1727,7 +1743,8 @@ func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
// Verify the caller owns this task's workspace.
|
||||
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
||||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1758,7 +1775,7 @@ func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
slog.Info("task completed", "task_id", taskID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||||
}
|
||||
|
||||
// emitIssueExecutedOnFirstCompletion atomically flips issue.first_executed_at
|
||||
@@ -1873,7 +1890,8 @@ func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
// Verify the caller owns this task's workspace.
|
||||
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
||||
_, workspaceID, ok := h.requireDaemonTaskAccessWithWorkspace(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1898,7 +1916,7 @@ func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
slog.Info("task failed", "task_id", taskID, "agent_id", uuidToString(task.AgentID), "task_error", req.Error, "failure_reason", req.FailureReason)
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task, workspaceID))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2056,9 +2074,10 @@ func (h *Handler) GetActiveTaskForIssue(w http.ResponseWriter, r *http.Request)
|
||||
tasks = nil
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
resp[i] = taskToResponse(t, workspaceID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": resp})
|
||||
@@ -2090,7 +2109,7 @@ func (h *Handler) CancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
slog.Info("task cancelled by user", "task_id", taskID, "issue_id", uuidToString(task.IssueID))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task, uuidToString(issue.WorkspaceID)))
|
||||
}
|
||||
|
||||
// ListTasksByIssue returns all tasks (any status) for an issue — used for execution history.
|
||||
@@ -2107,9 +2126,10 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := uuidToString(issue.WorkspaceID)
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
resp[i] = taskToResponse(t, workspaceID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
@@ -149,5 +149,5 @@ func (h *Handler) RerunIssue(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, taskToResponse(*task))
|
||||
writeJSON(w, http.StatusAccepted, taskToResponse(*task, uuidToString(issue.WorkspaceID)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user