feat(clone): background clone jobs that survive a reload and a server restart

Both clone POSTs take background: true: every check answers synchronously,
then 202 {job}. git runs in a detached runner (scripts/clone-runner.js, its
own process group, stderr to a file — no SIGPIPE when the server restarts);
the server tails it for progress and runs the after-step (workspace / extra
folder) once when it exits. Jobs persist in <data dir>/runtime/clone-jobs.json
(0600, atomic, throttled progress writes); on start a live runner is adopted
(counted against the clone limit), an ended one finished, a queued one
requeued (interrupted if it carried a typed login), the rest interrupted.
GET /clone/jobs, DELETE /clone/jobs/:id, POST /clone/jobs/:id/cancel (queued:
dequeued; running: SIGTERM to the runner's group -> cancelled, no after-step)
and /retry (new job from the stored request, never a credential). The
held-open path is unchanged; worktree.gitCommand builds the runner's argv
and git-identity.cloneFailure the shared error classification.
This commit is contained in:
2026-11-02 10:47:16 +07:00
parent 57cbab916a
commit a27589418c
13 changed files with 2033 additions and 143 deletions
+151 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.40
version: 2.8.50
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -17346,9 +17346,100 @@ paths:
type: string
pattern: ^[A-Za-z0-9_-]{16,64}$
description: The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.
background:
type: boolean
description: 'Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.'
responses:
'201':
description: '{lane, created: true, path}'
'202':
description: '`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).'
/api/lanes/clone/jobs:
get:
tags:
- Git identity
summary: The caller's background clone jobs, newest first
description: 'CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `<data dir>/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller''s own (local mode: all).'
operationId: listCloneJobs
responses:
'200':
description: '{jobs: CloneJob[]}'
/api/lanes/clone/jobs/{id}:
delete:
tags:
- Git identity
summary: Dismiss a finished background clone job
operationId: dismissCloneJob
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: '{ok: true}'
'404':
description: ENOTFOUND — not the caller's job
'409':
description: EBUSY — still queued or running
/api/lanes/clone/jobs/{id}/cancel:
post:
tags:
- Git identity
summary: Cancel a queued or running background clone
description: 'Queued: taken out of the queue, never started. Running: the runner''s process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.'
operationId: cancelCloneJob
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: '{job}'
'404':
description: ENOTFOUND — not the caller's job
'409':
description: EDONE — it already finished
/api/lanes/clone/jobs/{id}/retry:
post:
tags:
- Git identity
summary: Retry a failed, interrupted or cancelled background clone
description: A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.
operationId: retryCloneJob
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: []
properties:
credentials:
type: object
properties:
username:
type: string
password:
type: string
remember:
type: boolean
responses:
'202':
description: '{job} — the new CloneJob'
'404':
description: 'ENOTFOUND — not the caller''s job (ENOLANE: its workspace is gone)'
'409':
description: EBUSY — queued, running or done
/api/lanes/clone/progress/{token}:
get:
tags:
@@ -17841,7 +17932,7 @@ paths:
- Artifacts
summary: 'Skills from files: a file, a folder, a .zip/.skill or several skills'
operationId: uploadArtifactSkills
description: '`paths` is a JSON array of each file''s relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.'
description: '`paths` is a JSON array of each file''s relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace''s) becomes that row''s new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row''s name.'
requestBody:
required: true
content:
@@ -17864,6 +17955,9 @@ paths:
type: string
description:
type: string
id:
type: string
description: 'Share again: the skill row the upload replaces.'
responses:
'201':
description: Created.
@@ -19205,6 +19299,9 @@ paths:
type: object
additionalProperties: true
description: A typed HTTPS login for this one clone.
background:
type: boolean
description: 'Run as a background clone job (see `/api/lanes/clone/jobs`): the checks answer at once, then 202 `{job}` with `kind: "source"`.'
responses:
'201':
description: The context view plus `path` (the clone).
@@ -19213,6 +19310,8 @@ paths:
schema:
type: object
additionalProperties: true
'202':
description: '`background: true`: {job} — a CloneJob.'
'400':
description: EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).
'403':
@@ -22874,6 +22973,56 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/reshare:
post:
tags:
- Artifacts
summary: 'Share again: a skill''s newest version from its owner''s folder on this server'
operationId: reshareArtifact
description: 'The owner''s skill folder (the row''s source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.'
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
responses:
'200':
description: Taken.
content:
application/json:
schema:
type: object
properties:
item:
type: object
changed:
type: boolean
'400':
description: EBADKIND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EFORBIDDEN.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ENOSOURCE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/claim:
post:
tags: