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
|
const DEFAULT_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
body?: unknown
|
body?: unknown
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,82 @@ export class ApiException extends Error {
|
|||||||
this.body = body
|
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