6ceda6ae4e
* fix(local-executor): preserve commits in changes panel after refresh
For a local executor on a feature branch, the create-task dialog sent
the chip's branch as base_branch with empty checkout_branch. agentctl
then recomputed merge-base(HEAD, origin/<branch>) on every changes-panel
refresh; for a never-pushed branch that collapsed to HEAD and the panel
went empty. Repository creation also pinned default_branch to .git/HEAD,
permanently anchoring the repo's integration ref to whatever feature
branch the user happened to be on at first task-create time.
The frontend now splits the chip into base_branch=default_branch +
checkout_branch=working_branch (skipping the split when fresh-branch is
on, where the chip semantically means "fork from"). The backend probes
origin/HEAD then origin/main → main → master (new common/gitref
package) to pick a repo's integration branch, and resolveRepoInput no
longer falls back to BaseBranch when default_branch is missing.
Covered by 9 unit tests on the helper, 6 Go tests on the probe order,
and 7 e2e tests across plain creation, the fork toggle, and the
validate-path probe.
* fix: address PR review feedback
- gitref: preserve slashes in HEAD-fallback branch names (TrimPrefix
refs/heads/ instead of Split + last component); branches like
"feature/my-feature" no longer collapse to "my-feature" and poison
the stored default_branch.
- gitref: drop the broken packed-refs symref scan in readOriginHEAD;
the loose refs/remotes/origin/HEAD check + pickFirstExistingBranch
candidates already cover every realistic clone.
- service.resolveRepoInput: validate repoInput.LocalPath via
resolveAllowedLocalPath() before probing — closes 8 CodeQL
path-injection alerts.
- buildRepositoriesPayload: fall through to the legacy non-split shape
when default_branch is unknown for the row (legacy repos with unset
default_branch). Synthesizing base_branch=rowBranch was reproducing
the very bug this PR fixes.
* fix(gitref): explicit traversal guard at I/O boundary for CodeQL
Move the path validation from service.resolveAllowedLocalPath (which
CodeQL's taint analysis doesn't recognize as a sanitizer) into a
guardRepoPath check at gitref.DefaultBranch's entry. The new check
rejects relative paths and any '..' segments before any os.Stat /
os.ReadFile call sees the value, which CodeQL recognizes as a
go/path-injection sanitizer.
The upstream allowlist check stays in place — this is a belt-and-
suspenders second barrier at the actual I/O boundary, and it also
makes the gitref package safe for any future callers (migrations,
tests) without requiring them to thread a Service in.
* fix: address review feedback on hardening + e2e race
- gitref.guardRepoPath: check the RAW input for '..' segments before
filepath.Clean. Clean resolves '..' away from absolute paths
("/allowed/../etc" -> "/etc"), so iterating the cleaned path was a
silent no-op for the very inputs we wanted to reject. Split on both
'/' and the OS separator. Updated the test with three traversal
cases.
- gitref_test.go: switch from strings.EqualFold to strict equality —
git branch names are case-sensitive, "Main" should not pass for
"main".
- e2e: replace waitForRequest with waitForResponse in
local-executor-branch-split.spec.ts. waitForRequest resolves on send
(before the server has persisted the task), so the followup
apiClient.getTask call could race the write on slow CI. The two
tests that need the request body keep access via
(await waitForResponse).request().
* fix(ws): forward CheckoutBranch in wsUpdateTask
The PR fix added CheckoutBranch forwarding to wsCreateTask but missed
the same conversion loop in wsUpdateTask. Any task-update payload with
a repositories array would silently zero out checkout_branch and
re-introduce the merge-base collapse on refresh — same root cause this
PR fixes, in the update path.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
992 lines
29 KiB
TypeScript
992 lines
29 KiB
TypeScript
import type {
|
|
Workspace,
|
|
Workflow,
|
|
CreateTaskResponse,
|
|
ListWorkflowsResponse,
|
|
ListWorkflowStepsResponse,
|
|
} from "../../lib/types/http";
|
|
import type { Agent, AgentProfile } from "../../lib/types/http-agents";
|
|
|
|
// --- GitHub Mock Types ---
|
|
|
|
export type MockPR = {
|
|
number: number;
|
|
title: string;
|
|
state: string;
|
|
head_branch: string;
|
|
head_sha?: string;
|
|
base_branch: string;
|
|
author_login: string;
|
|
repo_owner: string;
|
|
repo_name: string;
|
|
html_url?: string;
|
|
url?: string;
|
|
body?: string;
|
|
draft?: boolean;
|
|
mergeable?: boolean;
|
|
mergeable_state?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
merged_at?: string;
|
|
requested_reviewers?: Array<{ login: string; type: string }>;
|
|
};
|
|
|
|
export type MockOrg = {
|
|
login: string;
|
|
avatar_url?: string;
|
|
};
|
|
|
|
export type MockRepo = {
|
|
full_name: string;
|
|
owner: string;
|
|
name: string;
|
|
private?: boolean;
|
|
};
|
|
|
|
export type MockReview = {
|
|
id: number;
|
|
author: string;
|
|
author_avatar?: string;
|
|
state: string;
|
|
body?: string;
|
|
created_at?: string;
|
|
};
|
|
|
|
export type MockCheckRun = {
|
|
name: string;
|
|
source?: string;
|
|
status: string;
|
|
conclusion?: string;
|
|
html_url?: string;
|
|
started_at?: string;
|
|
completed_at?: string;
|
|
};
|
|
|
|
function setIf(body: Record<string, unknown>, key: string, value: unknown) {
|
|
if (value !== undefined && value !== null) body[key] = value;
|
|
}
|
|
|
|
type CreateTaskOpts = {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
agent_profile_id?: string;
|
|
repository_ids?: string[];
|
|
repositories?: Array<{ repository_id: string; base_branch?: string; checkout_branch?: string }>;
|
|
plan_mode?: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
function buildTaskMetadata(opts: CreateTaskOpts): Record<string, unknown> | undefined {
|
|
const meta: Record<string, unknown> = { ...(opts.metadata ?? {}) };
|
|
if (opts.agent_profile_id && meta.agent_profile_id == null) {
|
|
meta.agent_profile_id = opts.agent_profile_id;
|
|
}
|
|
return Object.keys(meta).length > 0 ? meta : undefined;
|
|
}
|
|
|
|
function buildCreateTaskBody(
|
|
workspaceId: string,
|
|
title: string,
|
|
opts?: CreateTaskOpts,
|
|
): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
workspace_id: workspaceId,
|
|
title,
|
|
description: opts?.description ?? "",
|
|
};
|
|
setIf(body, "workflow_id", opts?.workflow_id);
|
|
setIf(body, "workflow_step_id", opts?.workflow_step_id);
|
|
setIf(body, "metadata", opts ? buildTaskMetadata(opts) : undefined);
|
|
setIf(
|
|
body,
|
|
"repositories",
|
|
opts?.repositories ?? opts?.repository_ids?.map((id) => ({ repository_id: id })),
|
|
);
|
|
if (opts?.plan_mode) body.plan_mode = true;
|
|
setIf(body, "parent_id", opts?.parent_id);
|
|
return body;
|
|
}
|
|
|
|
/** Build the optional fields object for createTaskWithAgent requests. */
|
|
function buildOptionalAgentTaskFields(opts?: {
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
repository_ids?: string[];
|
|
executor_id?: string;
|
|
executor_profile_id?: string;
|
|
metadata?: Record<string, unknown>;
|
|
parent_id?: string;
|
|
}): Record<string, unknown> {
|
|
const fields: Record<string, unknown> = {};
|
|
if (opts?.workflow_id) fields.workflow_id = opts.workflow_id;
|
|
if (opts?.workflow_step_id) fields.workflow_step_id = opts.workflow_step_id;
|
|
if (opts?.repository_ids)
|
|
fields.repositories = opts.repository_ids.map((id) => ({ repository_id: id }));
|
|
if (opts?.executor_id) fields.executor_id = opts.executor_id;
|
|
if (opts?.executor_profile_id) fields.executor_profile_id = opts.executor_profile_id;
|
|
if (opts?.metadata) fields.metadata = opts.metadata;
|
|
if (opts?.parent_id) fields.parent_id = opts.parent_id;
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* HTTP API client for seeding test data via the backend REST API.
|
|
*/
|
|
export class ApiClient {
|
|
constructor(private baseUrl: string) {}
|
|
|
|
/** Perform an HTTP request and return the raw Response (does not throw on non-2xx). */
|
|
async rawRequest(method: string, path: string, body?: unknown): Promise<Response> {
|
|
return fetch(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async healthCheck(): Promise<void> {
|
|
await this.request("GET", "/health");
|
|
}
|
|
|
|
async createWorkspace(name: string): Promise<Workspace> {
|
|
return this.request("POST", "/api/v1/workspaces", { name });
|
|
}
|
|
|
|
async createWorkflow(workspaceId: string, name: string, templateId?: string): Promise<Workflow> {
|
|
return this.request("POST", "/api/v1/workflows", {
|
|
workspace_id: workspaceId,
|
|
name,
|
|
...(templateId ? { workflow_template_id: templateId } : {}),
|
|
});
|
|
}
|
|
|
|
async reorderWorkflows(
|
|
workspaceId: string,
|
|
workflowIds: string[],
|
|
): Promise<{ success: boolean }> {
|
|
return this.request("PUT", `/api/v1/workspaces/${workspaceId}/workflows/reorder`, {
|
|
workflow_ids: workflowIds,
|
|
});
|
|
}
|
|
|
|
async createTask(
|
|
workspaceId: string,
|
|
title: string,
|
|
opts?: {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
/** Stored in task.Metadata so auto_start_agent can pick it up on on_enter. */
|
|
agent_profile_id?: string;
|
|
/** Repository IDs to associate with the task (required for agent execution). */
|
|
repository_ids?: string[];
|
|
/** Full repository entries with optional checkout_branch / base_branch. */
|
|
repositories?: Array<{
|
|
repository_id: string;
|
|
base_branch?: string;
|
|
checkout_branch?: string;
|
|
}>;
|
|
/** When true, task is placed at position 0 regardless of is_start_step. */
|
|
plan_mode?: boolean;
|
|
/** Extra metadata to store on the task. */
|
|
metadata?: Record<string, unknown>;
|
|
/** Parent task ID for subtasks. */
|
|
parent_id?: string;
|
|
},
|
|
): Promise<CreateTaskResponse> {
|
|
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
|
|
}
|
|
|
|
async updateTaskState(
|
|
taskId: string,
|
|
state: "BACKLOG" | "IN_PROGRESS" | "REVIEW" | "COMPLETED",
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state });
|
|
}
|
|
|
|
/** Rename a task. Used in tests that exercise live-title resolution on the
|
|
* cross-task message badge. */
|
|
async updateTaskTitle(taskId: string, title: string): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { title });
|
|
}
|
|
|
|
async listAgents(): Promise<{ agents: Agent[]; total: number }> {
|
|
return this.request("GET", "/api/v1/agents");
|
|
}
|
|
|
|
async deleteAgentProfile(profileId: string, force?: boolean): Promise<void> {
|
|
const qs = force ? "?force=true" : "";
|
|
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}${qs}`);
|
|
}
|
|
|
|
/** Delete all agent profiles except the ones in keepIds. */
|
|
async cleanupTestProfiles(keepIds: string[]): Promise<void> {
|
|
const { agents } = await this.listAgents();
|
|
for (const agent of agents) {
|
|
for (const profile of agent.profiles ?? []) {
|
|
if (!keepIds.includes(profile.id)) {
|
|
await this.deleteAgentProfile(profile.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async createAgentProfile(
|
|
agentId: string,
|
|
name: string,
|
|
opts: {
|
|
model: string;
|
|
mode?: string;
|
|
cli_passthrough?: boolean;
|
|
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
},
|
|
): Promise<{
|
|
id: string;
|
|
cli_flags: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
}> {
|
|
return this.request("POST", `/api/v1/agents/${agentId}/profiles`, {
|
|
name,
|
|
model: opts.model,
|
|
mode: opts.mode,
|
|
cli_passthrough: opts.cli_passthrough ?? false,
|
|
cli_flags: opts.cli_flags,
|
|
});
|
|
}
|
|
|
|
async getAgentProfile(profileId: string): Promise<AgentProfile> {
|
|
// The profile does not have its own GET endpoint; fetch via listAgents
|
|
// and find the matching row. Keeps the helper surface small.
|
|
const { agents } = await this.listAgents();
|
|
for (const agent of agents) {
|
|
for (const profile of agent.profiles ?? []) {
|
|
if (profile.id === profileId) {
|
|
return profile;
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`profile ${profileId} not found`);
|
|
}
|
|
|
|
async updateAgentProfile(
|
|
profileId: string,
|
|
patch: {
|
|
name?: string;
|
|
model?: string;
|
|
mode?: string;
|
|
cli_passthrough?: boolean;
|
|
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/agent-profiles/${profileId}`, patch);
|
|
}
|
|
|
|
async createTaskWithAgent(
|
|
workspaceId: string,
|
|
title: string,
|
|
agentProfileId: string,
|
|
opts?: {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
repository_ids?: string[];
|
|
executor_id?: string;
|
|
executor_profile_id?: string;
|
|
metadata?: Record<string, unknown>;
|
|
/** Parent task ID for subtasks. */
|
|
parent_id?: string;
|
|
},
|
|
): Promise<CreateTaskResponse> {
|
|
return this.request("POST", "/api/v1/tasks", {
|
|
workspace_id: workspaceId,
|
|
title,
|
|
description: opts?.description ?? "",
|
|
start_agent: true,
|
|
agent_profile_id: agentProfileId,
|
|
...buildOptionalAgentTaskFields(opts),
|
|
});
|
|
}
|
|
|
|
/** Start a config chat session via the dedicated config-chat endpoint. */
|
|
async startConfigChat(
|
|
workspaceId: string,
|
|
agentProfileId: string,
|
|
prompt: string,
|
|
): Promise<{ task_id: string; session_id: string }> {
|
|
return this.request("POST", `/api/v1/workspaces/${workspaceId}/config-chat`, {
|
|
agent_profile_id: agentProfileId,
|
|
prompt,
|
|
});
|
|
}
|
|
|
|
async listWorkflows(workspaceId: string): Promise<ListWorkflowsResponse> {
|
|
return this.request("GET", `/api/v1/workspaces/${workspaceId}/workflows`);
|
|
}
|
|
|
|
async listWorkflowSteps(workflowId: string): Promise<ListWorkflowStepsResponse> {
|
|
return this.request("GET", `/api/v1/workflows/${workflowId}/workflow/steps`);
|
|
}
|
|
|
|
async createWorkflowStep(
|
|
workflowId: string,
|
|
name: string,
|
|
position: number,
|
|
opts?: { is_start_step?: boolean },
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", `/api/v1/workflow/steps`, {
|
|
workflow_id: workflowId,
|
|
name,
|
|
position,
|
|
...(opts?.is_start_step != null ? { is_start_step: opts.is_start_step } : {}),
|
|
});
|
|
}
|
|
|
|
async createRepository(
|
|
workspaceId: string,
|
|
localPath: string,
|
|
defaultBranch = "main",
|
|
opts?: {
|
|
name?: string;
|
|
provider?: string;
|
|
provider_owner?: string;
|
|
provider_name?: string;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", `/api/v1/workspaces/${workspaceId}/repositories`, {
|
|
name: opts?.name ?? "E2E Repo",
|
|
source_type: "local",
|
|
local_path: localPath,
|
|
default_branch: defaultBranch,
|
|
...(opts?.provider ? { provider: opts.provider } : {}),
|
|
...(opts?.provider_owner ? { provider_owner: opts.provider_owner } : {}),
|
|
...(opts?.provider_name ? { provider_name: opts.provider_name } : {}),
|
|
});
|
|
}
|
|
|
|
async updateRepository(
|
|
repositoryId: string,
|
|
updates: { dev_script?: string; setup_script?: string; cleanup_script?: string },
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/repositories/${repositoryId}`, updates);
|
|
}
|
|
|
|
async createRepositoryScript(
|
|
repositoryId: string,
|
|
name: string,
|
|
command: string,
|
|
position = 0,
|
|
): Promise<{
|
|
id: string;
|
|
repository_id: string;
|
|
name: string;
|
|
command: string;
|
|
position: number;
|
|
}> {
|
|
return this.request("POST", `/api/v1/repositories/${repositoryId}/scripts`, {
|
|
name,
|
|
command,
|
|
position,
|
|
});
|
|
}
|
|
|
|
async createExecutor(
|
|
name: string,
|
|
type: string,
|
|
): Promise<{ id: string; name: string; type: string }> {
|
|
return this.request("POST", "/api/v1/executors", { name, type });
|
|
}
|
|
|
|
async updateWorkspace(
|
|
workspaceId: string,
|
|
updates: {
|
|
default_executor_id?: string;
|
|
default_agent_profile_id?: string;
|
|
default_config_agent_profile_id?: string;
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/workspaces/${workspaceId}`, updates);
|
|
}
|
|
|
|
async deleteExecutor(executorId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/executors/${executorId}`);
|
|
}
|
|
|
|
async createExecutorProfile(
|
|
executorId: string,
|
|
name: string,
|
|
opts?: { mcp_policy?: string; prepare_script?: string; cleanup_script?: string },
|
|
): Promise<{ id: string; name: string }> {
|
|
return this.request("POST", `/api/v1/executors/${executorId}/profiles`, {
|
|
name,
|
|
...(opts?.mcp_policy ? { mcp_policy: opts.mcp_policy } : {}),
|
|
...(opts?.prepare_script ? { prepare_script: opts.prepare_script } : {}),
|
|
...(opts?.cleanup_script ? { cleanup_script: opts.cleanup_script } : {}),
|
|
});
|
|
}
|
|
|
|
async deleteExecutorProfile(profileId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/executor-profiles/${profileId}`);
|
|
}
|
|
|
|
async listExecutors(): Promise<{
|
|
executors: Array<{
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
profiles?: Array<{ id: string; name: string }>;
|
|
}>;
|
|
}> {
|
|
return this.request("GET", "/api/v1/executors");
|
|
}
|
|
|
|
async getUserSettings(): Promise<{
|
|
settings: {
|
|
terminal_link_behavior?: string;
|
|
terminal_font_family?: string;
|
|
terminal_font_size?: number;
|
|
[key: string]: unknown;
|
|
};
|
|
}> {
|
|
return this.request("GET", "/api/v1/user/settings");
|
|
}
|
|
|
|
async saveUserSettings(settings: {
|
|
enable_preview_on_click?: boolean;
|
|
workspace_id?: string;
|
|
workflow_filter_id?: string;
|
|
repository_ids?: string[];
|
|
terminal_link_behavior?: "new_tab" | "browser_panel";
|
|
terminal_font_family?: string;
|
|
terminal_font_size?: number;
|
|
keyboard_shortcuts?: Record<string, unknown>;
|
|
default_utility_agent_id?: string;
|
|
default_utility_model?: string;
|
|
sidebar_views?: unknown[];
|
|
}): Promise<void> {
|
|
await this.request("PATCH", "/api/v1/user/settings", settings);
|
|
}
|
|
|
|
async moveTask(taskId: string, workflowId: string, workflowStepId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/tasks/${taskId}/move`, {
|
|
workflow_id: workflowId,
|
|
workflow_step_id: workflowStepId,
|
|
});
|
|
}
|
|
|
|
async updateWorkflow(
|
|
workflowId: string,
|
|
updates: { name?: string; description?: string; agent_profile_id?: string },
|
|
): Promise<Workflow> {
|
|
return this.request("PATCH", `/api/v1/workflows/${workflowId}`, updates);
|
|
}
|
|
|
|
async updateWorkflowStep(
|
|
stepId: string,
|
|
updates: {
|
|
prompt?: string;
|
|
agent_profile_id?: string;
|
|
events?: {
|
|
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
};
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates });
|
|
}
|
|
|
|
async deleteWorkflow(workflowId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/workflows/${workflowId}`);
|
|
}
|
|
|
|
async deleteWorkflowStep(stepId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/workflow/steps/${stepId}`);
|
|
}
|
|
|
|
async listWorkflowTemplates(): Promise<{
|
|
templates: Array<{ id: string; name: string; default_steps?: Array<{ name: string }> }>;
|
|
}> {
|
|
return this.request("GET", "/api/v1/workflow/templates");
|
|
}
|
|
|
|
// --- Workflow Export/Import ---
|
|
|
|
async exportWorkflow(workflowId: string): Promise<string> {
|
|
const res = await this.rawRequest("GET", `/api/v1/workflows/${workflowId}/export`);
|
|
if (!res.ok) throw new Error(`Export failed (${res.status}): ${await res.text()}`);
|
|
return res.text();
|
|
}
|
|
|
|
async exportAllWorkflows(workspaceId: string): Promise<string> {
|
|
const res = await this.rawRequest("GET", `/api/v1/workspaces/${workspaceId}/workflows/export`);
|
|
if (!res.ok) throw new Error(`Export failed (${res.status}): ${await res.text()}`);
|
|
return res.text();
|
|
}
|
|
|
|
async importWorkflows(
|
|
workspaceId: string,
|
|
yamlContent: string,
|
|
): Promise<{ created: string[]; skipped: string[] }> {
|
|
const res = await fetch(`${this.baseUrl}/api/v1/workspaces/${workspaceId}/workflows/import`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-yaml" },
|
|
body: yamlContent,
|
|
});
|
|
if (!res.ok) throw new Error(`Import failed (${res.status}): ${await res.text()}`);
|
|
return res.json() as Promise<{ created: string[]; skipped: string[] }>;
|
|
}
|
|
|
|
async deleteTask(taskId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/tasks/${taskId}`);
|
|
}
|
|
|
|
async archiveTask(taskId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/tasks/${taskId}/archive`);
|
|
}
|
|
|
|
async getAgentProfileMcpConfig(
|
|
profileId: string,
|
|
): Promise<{ profile_id: string; enabled: boolean; servers: Record<string, unknown> }> {
|
|
return this.request("GET", `/api/v1/agent-profiles/${profileId}/mcp-config`);
|
|
}
|
|
|
|
// --- E2E Test Reset ---
|
|
|
|
async e2eReset(workspaceId: string, keepWorkflowIds?: string[]): Promise<void> {
|
|
const params = keepWorkflowIds?.length ? `?keep_workflows=${keepWorkflowIds.join(",")}` : "";
|
|
await this.request("DELETE", `/api/v1/e2e/reset/${workspaceId}${params}`);
|
|
}
|
|
|
|
// --- GitHub Mock Control ---
|
|
|
|
async mockGitHubReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/github/mock/reset");
|
|
}
|
|
|
|
async mockGitHubSetUser(username: string): Promise<void> {
|
|
await this.request("PUT", "/api/v1/github/mock/user", { username });
|
|
}
|
|
|
|
async mockGitHubAddPRs(prs: MockPR[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/prs", { prs });
|
|
}
|
|
|
|
async mockGitHubAddOrgs(orgs: MockOrg[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/orgs", { orgs });
|
|
}
|
|
|
|
async mockGitHubAddRepos(org: string, repos: MockRepo[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/repos", { org, repos });
|
|
}
|
|
|
|
async mockGitHubAddReviews(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
reviews: MockReview[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/reviews", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
reviews,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddCheckRuns(
|
|
owner: string,
|
|
repo: string,
|
|
ref: string,
|
|
checks: MockCheckRun[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/checks", {
|
|
owner,
|
|
repo,
|
|
ref,
|
|
checks,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddPRFiles(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
files: Array<{
|
|
filename: string;
|
|
status: string;
|
|
additions: number;
|
|
deletions: number;
|
|
patch?: string;
|
|
}>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/files", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
files,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddPRCommits(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
commits: Array<{
|
|
sha: string;
|
|
message: string;
|
|
author_login: string;
|
|
author_date: string;
|
|
}>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/commits", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
commits,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddBranches(
|
|
owner: string,
|
|
repo: string,
|
|
branches: Array<{ name: string }>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/branches", {
|
|
owner,
|
|
repo,
|
|
branches,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAssociateTaskPR(data: {
|
|
task_id: string;
|
|
owner: string;
|
|
repo: string;
|
|
pr_number: number;
|
|
pr_url: string;
|
|
pr_title: string;
|
|
head_branch: string;
|
|
base_branch: string;
|
|
author_login: string;
|
|
state?: string;
|
|
review_state?: string;
|
|
checks_state?: string;
|
|
mergeable_state?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
}): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/task-prs", data);
|
|
}
|
|
|
|
async mockGitHubGetStatus(): Promise<{
|
|
authenticated: boolean;
|
|
username: string;
|
|
auth_method: string;
|
|
}> {
|
|
return this.request("GET", "/api/v1/github/status");
|
|
}
|
|
|
|
// --- Session ---
|
|
|
|
async listSessionMessages(sessionId: string): Promise<{
|
|
messages: Array<{
|
|
id: string;
|
|
content: string;
|
|
author_type: string;
|
|
raw_content?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}>;
|
|
}> {
|
|
return this.request("GET", `/api/v1/task-sessions/${sessionId}/messages`);
|
|
}
|
|
|
|
async listTasks(
|
|
workspaceId: string,
|
|
): Promise<{ tasks: Array<{ id: string; title: string; workflow_step_id?: string }> }> {
|
|
return this.request("GET", `/api/v1/workspaces/${workspaceId}/tasks`);
|
|
}
|
|
|
|
async listTaskSessions(taskId: string): Promise<{
|
|
sessions: Array<{
|
|
id: string;
|
|
task_id: string;
|
|
agent_profile_id?: string;
|
|
state: string;
|
|
started_at: string;
|
|
task_environment_id?: string;
|
|
worktree_path?: string;
|
|
worktree_branch?: string;
|
|
}>;
|
|
total: number;
|
|
}> {
|
|
return this.request("GET", `/api/v1/tasks/${taskId}/sessions`);
|
|
}
|
|
|
|
async setPrimarySession(sessionId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-primary`);
|
|
}
|
|
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/task-sessions/${sessionId}`);
|
|
}
|
|
|
|
async getTask(taskId: string): Promise<{
|
|
id: string;
|
|
title: string;
|
|
primary_session_id?: string | null;
|
|
state?: string;
|
|
repositories?: Array<{
|
|
id: string;
|
|
task_id: string;
|
|
repository_id: string;
|
|
base_branch: string;
|
|
checkout_branch?: string;
|
|
position: number;
|
|
}>;
|
|
}> {
|
|
return this.request("GET", `/api/v1/tasks/${taskId}`);
|
|
}
|
|
|
|
async getTaskEnvironment(taskId: string): Promise<{
|
|
id: string;
|
|
task_id: string;
|
|
worktree_id?: string;
|
|
worktree_path?: string;
|
|
status: string;
|
|
} | null> {
|
|
const res = await this.rawRequest("GET", `/api/v1/tasks/${taskId}/environment`);
|
|
if (res.status === 404) return null;
|
|
if (!res.ok) {
|
|
throw new Error(`getTaskEnvironment failed (${res.status}): ${await res.text()}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// --- GitHub Review Watch ---
|
|
|
|
async createReviewWatch(
|
|
workspaceId: string,
|
|
workflowId: string,
|
|
workflowStepId: string,
|
|
agentProfileId: string,
|
|
opts?: {
|
|
repos?: Array<{ owner: string; name: string }>;
|
|
prompt?: string;
|
|
review_scope?: string;
|
|
poll_interval_seconds?: number;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", "/api/v1/github/watches/review", {
|
|
workspace_id: workspaceId,
|
|
workflow_id: workflowId,
|
|
workflow_step_id: workflowStepId,
|
|
agent_profile_id: agentProfileId,
|
|
repos: opts?.repos ?? [],
|
|
prompt: opts?.prompt ?? "",
|
|
review_scope: opts?.review_scope ?? "user_and_teams",
|
|
poll_interval_seconds: opts?.poll_interval_seconds ?? 300,
|
|
});
|
|
}
|
|
|
|
async triggerReviewWatch(watchId: string): Promise<{ new_prs: number; cleaned?: number }> {
|
|
return this.request("POST", `/api/v1/github/watches/review/${watchId}/trigger`, undefined);
|
|
}
|
|
|
|
// --- Integration config seeding (real API, not mock) ---
|
|
|
|
async setJiraConfig(payload: {
|
|
siteUrl: string;
|
|
email: string;
|
|
authMethod?: "api_token" | "session_cookie";
|
|
defaultProjectKey?: string;
|
|
secret: string;
|
|
}): Promise<unknown> {
|
|
return this.request("POST", "/api/v1/jira/config", {
|
|
...payload,
|
|
authMethod: payload.authMethod ?? "api_token",
|
|
});
|
|
}
|
|
|
|
async setLinearConfig(payload: { secret: string; defaultTeamKey?: string }): Promise<unknown> {
|
|
return this.request("POST", "/api/v1/linear/config", {
|
|
defaultTeamKey: "",
|
|
...payload,
|
|
authMethod: "api_key",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Poll until the integration config reports `lastOk: true`. SetConfig kicks
|
|
* off an async auth-health probe in a goroutine, so the row's `lastOk` flips
|
|
* shortly after — but with no synchronous signal. Tests that gate UI on
|
|
* `useJiraAvailable` / `useLinearAvailable` need to await this before
|
|
* navigating, otherwise the import bar can race the first render.
|
|
*/
|
|
async waitForIntegrationAuthHealthy(
|
|
integration: "jira" | "linear",
|
|
timeoutMs = 5_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const res = await this.rawRequest("GET", `/api/v1/${integration}/config`);
|
|
if (res.ok && res.status === 200) {
|
|
const cfg = (await res.json()) as { hasSecret?: boolean; lastOk?: boolean };
|
|
if (cfg.hasSecret && cfg.lastOk) return;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
throw new Error(`${integration} config never reported lastOk: true within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// --- Jira Mock Control ---
|
|
|
|
async mockJiraReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/jira/mock/reset");
|
|
}
|
|
|
|
async mockJiraSetAuthResult(result: {
|
|
ok: boolean;
|
|
accountId?: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
error?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/auth-result", result);
|
|
}
|
|
|
|
async mockJiraSetAuthHealth(args: { ok: boolean; error?: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/auth-health", args);
|
|
}
|
|
|
|
async mockJiraSetProjects(projects: MockJiraProject[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/projects", { projects });
|
|
}
|
|
|
|
async mockJiraAddTickets(tickets: MockJiraTicket[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/tickets", { tickets });
|
|
}
|
|
|
|
async mockJiraAddTransitions(
|
|
ticketKey: string,
|
|
transitions: MockJiraTransition[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/transitions", {
|
|
ticketKey,
|
|
transitions,
|
|
});
|
|
}
|
|
|
|
async mockJiraSetSearchHits(tickets: MockJiraTicket[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/search-hits", { tickets });
|
|
}
|
|
|
|
async mockJiraSetGetTicketError(args: { statusCode: number; message: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/get-ticket-error", args);
|
|
}
|
|
|
|
// --- Linear Mock Control ---
|
|
|
|
async mockLinearReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/linear/mock/reset");
|
|
}
|
|
|
|
async mockLinearSetAuthResult(result: {
|
|
ok: boolean;
|
|
userId?: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
orgSlug?: string;
|
|
orgName?: string;
|
|
error?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/auth-result", result);
|
|
}
|
|
|
|
async mockLinearSetAuthHealth(args: {
|
|
ok: boolean;
|
|
error?: string;
|
|
orgSlug?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/auth-health", args);
|
|
}
|
|
|
|
async mockLinearSetTeams(teams: MockLinearTeam[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/teams", { teams });
|
|
}
|
|
|
|
async mockLinearSetStates(teamKey: string, states: MockLinearState[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/states", { teamKey, states });
|
|
}
|
|
|
|
async mockLinearAddIssues(issues: MockLinearIssue[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/issues", { issues });
|
|
}
|
|
|
|
async mockLinearSetGetIssueError(args: { statusCode: number; message: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/get-issue-error", args);
|
|
}
|
|
}
|
|
|
|
// --- Jira / Linear mock payload types ---
|
|
|
|
export type MockJiraProject = { id: string; key: string; name: string };
|
|
|
|
export type MockJiraTransition = {
|
|
id: string;
|
|
name: string;
|
|
toStatusId: string;
|
|
toStatusName: string;
|
|
};
|
|
|
|
export type MockJiraTicket = {
|
|
key: string;
|
|
summary?: string;
|
|
description?: string;
|
|
statusId?: string;
|
|
statusName?: string;
|
|
statusCategory?: string;
|
|
projectKey?: string;
|
|
issueType?: string;
|
|
url?: string;
|
|
transitions?: MockJiraTransition[];
|
|
};
|
|
|
|
export type MockLinearTeam = { id: string; key: string; name: string };
|
|
|
|
export type MockLinearState = {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
color?: string;
|
|
position?: number;
|
|
};
|
|
|
|
export type MockLinearIssue = {
|
|
id: string;
|
|
identifier: string;
|
|
title?: string;
|
|
description?: string;
|
|
stateId?: string;
|
|
stateName?: string;
|
|
stateType?: string;
|
|
stateCategory?: string;
|
|
teamId?: string;
|
|
teamKey?: string;
|
|
priority?: number;
|
|
url?: string;
|
|
};
|