You've already forked agentic-coding-workflow
feat(web): api client and pinia store for workspace/worktree
This commit is contained in:
@@ -4,7 +4,7 @@ const BASE = '' // 同源;dev 走 vite proxy
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -15,3 +15,82 @@ export class ApiException extends Error {
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncStatus = 'cloning' | 'idle' | 'syncing' | 'error'
|
||||
export type WorktreeStatus = 'idle' | 'active' | 'pruning'
|
||||
export type CredentialKind = 'pat' | 'ssh_key' | 'none'
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
project_id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
git_remote_url: string
|
||||
default_branch: string
|
||||
sync_status: SyncStatus
|
||||
last_synced_at: string | null
|
||||
last_sync_error: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Worktree {
|
||||
id: string
|
||||
workspace_id: string
|
||||
branch: string
|
||||
path: string
|
||||
status: WorktreeStatus
|
||||
active_holder: string
|
||||
acquired_at: string | null
|
||||
last_used_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CredentialMeta {
|
||||
kind: CredentialKind
|
||||
username: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface CredentialUpsertBody {
|
||||
kind: CredentialKind
|
||||
username?: string
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export interface FileStatus {
|
||||
path: string
|
||||
index_x: string
|
||||
worktree_y: string
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceBody {
|
||||
slug: string
|
||||
name: string
|
||||
description?: string
|
||||
git_remote_url: string
|
||||
default_branch?: string
|
||||
credential: CredentialUpsertBody
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceBody {
|
||||
name?: string
|
||||
description?: string
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export interface CreateWorktreeBody {
|
||||
branch: string
|
||||
base_branch?: string
|
||||
}
|
||||
|
||||
export interface CommitBody {
|
||||
message: string
|
||||
push?: boolean
|
||||
add_all?: boolean
|
||||
}
|
||||
|
||||
export interface CommitResp {
|
||||
sha: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { request } from './client'
|
||||
import type {
|
||||
Workspace,
|
||||
Worktree,
|
||||
CredentialMeta,
|
||||
CredentialUpsertBody,
|
||||
FileStatus,
|
||||
CreateWorkspaceBody,
|
||||
UpdateWorkspaceBody,
|
||||
CreateWorktreeBody,
|
||||
CommitBody,
|
||||
CommitResp,
|
||||
} from './types'
|
||||
|
||||
const enc = (v: string) => encodeURIComponent(v)
|
||||
|
||||
export const workspacesApi = {
|
||||
// ===== Workspace =====
|
||||
list: (projectSlug: string) =>
|
||||
request<Workspace[]>(`/api/v1/projects/${enc(projectSlug)}/workspaces/`),
|
||||
create: (projectSlug: string, body: CreateWorkspaceBody) =>
|
||||
request<Workspace>(`/api/v1/projects/${enc(projectSlug)}/workspaces/`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
get: (wsID: string) => request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`),
|
||||
update: (wsID: string, body: UpdateWorkspaceBody) =>
|
||||
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'PATCH', body }),
|
||||
delete: (wsID: string) =>
|
||||
request<void>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }),
|
||||
sync: (wsID: string) =>
|
||||
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}/sync`, { method: 'POST' }),
|
||||
|
||||
// ===== Credential =====
|
||||
upsertCredential: (wsID: string, body: CredentialUpsertBody) =>
|
||||
request<CredentialMeta>(`/api/v1/workspaces/${enc(wsID)}/credential`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
getCredential: (wsID: string) =>
|
||||
request<CredentialMeta>(`/api/v1/workspaces/${enc(wsID)}/credential`),
|
||||
|
||||
// ===== Worktree =====
|
||||
listWorktrees: (wsID: string) =>
|
||||
request<Worktree[]>(`/api/v1/workspaces/${enc(wsID)}/worktrees`),
|
||||
createWorktree: (wsID: string, body: CreateWorktreeBody) =>
|
||||
request<Worktree>(`/api/v1/workspaces/${enc(wsID)}/worktrees`, { method: 'POST', body }),
|
||||
deleteWorktree: (wtID: string) =>
|
||||
request<void>(`/api/v1/worktrees/${enc(wtID)}`, { method: 'DELETE' }),
|
||||
acquire: (wtID: string) =>
|
||||
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/acquire`, { method: 'POST' }),
|
||||
release: (wtID: string) =>
|
||||
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
|
||||
|
||||
// ===== Git ops =====
|
||||
statusOnMain: (wsID: string) =>
|
||||
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
|
||||
commitOnMain: (wsID: string, body: CommitBody) =>
|
||||
request<CommitResp>(`/api/v1/workspaces/${enc(wsID)}/main/commit`, { method: 'POST', body }),
|
||||
pushOnMain: (wsID: string) =>
|
||||
request<void>(`/api/v1/workspaces/${enc(wsID)}/main/push`, { method: 'POST' }),
|
||||
statusOnWorktree: (wtID: string) =>
|
||||
request<FileStatus[]>(`/api/v1/worktrees/${enc(wtID)}/status`),
|
||||
commitOnWorktree: (wtID: string, body: CommitBody) =>
|
||||
request<CommitResp>(`/api/v1/worktrees/${enc(wtID)}/commit`, { method: 'POST', body }),
|
||||
pushOnWorktree: (wtID: string) =>
|
||||
request<void>(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }),
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import type {
|
||||
Workspace,
|
||||
Worktree,
|
||||
FileStatus,
|
||||
CredentialMeta,
|
||||
CredentialUpsertBody,
|
||||
CreateWorkspaceBody,
|
||||
UpdateWorkspaceBody,
|
||||
CreateWorktreeBody,
|
||||
CommitBody,
|
||||
} from '@/api/types'
|
||||
|
||||
export const useWorkspacesStore = defineStore('workspaces', () => {
|
||||
const list = ref<Workspace[]>([])
|
||||
const current = ref<Workspace | null>(null)
|
||||
const worktrees = ref<Worktree[]>([])
|
||||
const credential = ref<CredentialMeta | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchByProject(projectSlug: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await workspacesApi.list(projectSlug)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOne(wsID: string) {
|
||||
current.value = await workspacesApi.get(wsID)
|
||||
}
|
||||
|
||||
async function create(projectSlug: string, body: CreateWorkspaceBody) {
|
||||
const ws = await workspacesApi.create(projectSlug, body)
|
||||
list.value.unshift(ws)
|
||||
return ws
|
||||
}
|
||||
|
||||
async function update(wsID: string, body: UpdateWorkspaceBody) {
|
||||
const ws = await workspacesApi.update(wsID, body)
|
||||
if (current.value?.id === wsID) current.value = ws
|
||||
// 若 wsID 不在 list 中(如深链直入 Detail 页未加载列表),map 静默 no-op;调用方按需 fetchByProject。
|
||||
list.value = list.value.map((w) => (w.id === wsID ? ws : w))
|
||||
return ws
|
||||
}
|
||||
|
||||
async function remove(wsID: string) {
|
||||
await workspacesApi.delete(wsID)
|
||||
list.value = list.value.filter((w) => w.id !== wsID)
|
||||
if (current.value?.id === wsID) current.value = null
|
||||
}
|
||||
|
||||
async function sync(wsID: string) {
|
||||
const ws = await workspacesApi.sync(wsID)
|
||||
if (current.value?.id === wsID) current.value = ws
|
||||
// 同 update:list 中无此 wsID 时 map 是 no-op,调用方负责拉列表。
|
||||
list.value = list.value.map((w) => (w.id === wsID ? ws : w))
|
||||
return ws
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询 workspace.sync_status 直到落到 idle/error,用于 Phase 9 创建/Sync 后等克隆完成。
|
||||
*
|
||||
* 调用方约定:
|
||||
* - 若已知 ws 在 list 中,调用前请确保该项已加载(否则 list.map 静默 no-op,但 current 仍会刷新)。
|
||||
* - 失败抛出 Error,message 含最后一次观测到的 sync_status,便于 UI 区分超时类型。
|
||||
* - 调用方应自行处理并发:同一 wsID 多次并发轮询不会共享请求。
|
||||
*
|
||||
* @param wsID 目标 workspace 的 UUID。
|
||||
* @param intervalMs 轮询间隔,默认 1500ms。
|
||||
* @param maxMs 总超时上限,默认 5 分钟(覆盖中等仓库克隆耗时)。
|
||||
*/
|
||||
async function pollUntilSettled(
|
||||
wsID: string,
|
||||
intervalMs = 1500,
|
||||
maxMs = 5 * 60 * 1000,
|
||||
): Promise<Workspace> {
|
||||
const start = Date.now()
|
||||
let lastStatus: Workspace['sync_status'] | 'unknown' = 'unknown'
|
||||
while (Date.now() - start < maxMs) {
|
||||
const ws = await workspacesApi.get(wsID)
|
||||
lastStatus = ws.sync_status
|
||||
if (current.value?.id === wsID) current.value = ws
|
||||
// 同 update:list 中无此 wsID 时 map 是 no-op,仍刷新 current.value。
|
||||
list.value = list.value.map((w) => (w.id === wsID ? ws : w))
|
||||
if (ws.sync_status !== 'cloning' && ws.sync_status !== 'syncing') return ws
|
||||
await new Promise((r) => setTimeout(r, intervalMs))
|
||||
}
|
||||
throw new Error(`sync polling timed out for workspace ${wsID} (last status: ${lastStatus})`)
|
||||
}
|
||||
|
||||
// ===== credential =====
|
||||
async function fetchCredential(wsID: string) {
|
||||
credential.value = await workspacesApi.getCredential(wsID)
|
||||
}
|
||||
async function upsertCredential(wsID: string, body: CredentialUpsertBody) {
|
||||
credential.value = await workspacesApi.upsertCredential(wsID, body)
|
||||
}
|
||||
|
||||
// ===== worktrees =====
|
||||
async function fetchWorktrees(wsID: string) {
|
||||
worktrees.value = await workspacesApi.listWorktrees(wsID)
|
||||
}
|
||||
async function createWorktree(wsID: string, body: CreateWorktreeBody) {
|
||||
const wt = await workspacesApi.createWorktree(wsID, body)
|
||||
worktrees.value.unshift(wt)
|
||||
return wt
|
||||
}
|
||||
async function deleteWorktree(wtID: string) {
|
||||
await workspacesApi.deleteWorktree(wtID)
|
||||
worktrees.value = worktrees.value.filter((w) => w.id !== wtID)
|
||||
}
|
||||
async function acquire(wtID: string) {
|
||||
const wt = await workspacesApi.acquire(wtID)
|
||||
worktrees.value = worktrees.value.map((w) => (w.id === wtID ? wt : w))
|
||||
return wt
|
||||
}
|
||||
async function release(wtID: string) {
|
||||
const wt = await workspacesApi.release(wtID)
|
||||
worktrees.value = worktrees.value.map((w) => (w.id === wtID ? wt : w))
|
||||
return wt
|
||||
}
|
||||
|
||||
// ===== git ops(直透 api,store 仅负责状态变化的端点;status/commit/push 不缓存)=====
|
||||
async function statusOnMain(wsID: string): Promise<FileStatus[]> {
|
||||
return workspacesApi.statusOnMain(wsID)
|
||||
}
|
||||
async function statusOnWorktree(wtID: string): Promise<FileStatus[]> {
|
||||
return workspacesApi.statusOnWorktree(wtID)
|
||||
}
|
||||
async function commitOnMain(wsID: string, body: CommitBody) {
|
||||
return workspacesApi.commitOnMain(wsID, body)
|
||||
}
|
||||
async function commitOnWorktree(wtID: string, body: CommitBody) {
|
||||
return workspacesApi.commitOnWorktree(wtID, body)
|
||||
}
|
||||
async function pushOnMain(wsID: string) {
|
||||
return workspacesApi.pushOnMain(wsID)
|
||||
}
|
||||
async function pushOnWorktree(wtID: string) {
|
||||
return workspacesApi.pushOnWorktree(wtID)
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
worktrees,
|
||||
credential,
|
||||
loading,
|
||||
fetchByProject,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
sync,
|
||||
pollUntilSettled,
|
||||
fetchCredential,
|
||||
upsertCredential,
|
||||
fetchWorktrees,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
acquire,
|
||||
release,
|
||||
statusOnMain,
|
||||
statusOnWorktree,
|
||||
commitOnMain,
|
||||
commitOnWorktree,
|
||||
pushOnMain,
|
||||
pushOnWorktree,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user