Files
AgentHub/server/openapi-extra/files.js
T
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00

689 lines
24 KiB
JavaScript

/**
* @file OpenAPI fragment for the File Explorer — the same operations on a
* workspace (`/api/lanes/{id}/fs/*`, also under /api/workspaces) and on a
* user's home (`/api/homes/{who}/fs/*`), generated from one table so the two
* cannot drift — plus `GET /api/homes`, Add workspace → Clone
* (`POST /api/lanes/clone`) and the user's own git identity (`/api/me/git`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Files",
description:
"The File Explorer on a workspace or a user's home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder's account; a taken name is settled by `conflict`.",
},
{
name: "Git identity",
description:
"A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them.",
},
];
const errorCodes =
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG; a resumable upload adds 409 EOFFSET/EINCOMPLETE (with `error.received`)/EBUSY and 429 ETOOMANY.";
const schemas = {
FsEntry: {
type: "object",
properties: {
name: { type: "string" },
path: { type: "string", description: "Relative to the workspace or home." },
type: { type: "string", enum: ["dir", "file", "link", "linkdir", "other"] },
size: { type: "integer", nullable: true },
mtime: { type: "number", description: "Milliseconds since the epoch." },
},
},
FsListing: {
type: "object",
properties: {
path: { type: "string" },
parent: { type: "string", nullable: true },
entries: { type: "array", items: { $ref: "#/components/schemas/FsEntry" } },
truncated: { type: "boolean" },
},
},
FsConflict: {
type: "string",
enum: ["fail", "skip", "keep", "replace"],
default: "fail",
description:
"A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.",
},
};
const json = (properties, required = []) => ({
required: true,
content: { "application/json": { schema: { type: "object", required, properties } } },
});
const pathsArr = { type: "array", items: { type: "string" } };
const conflict = { $ref: "#/components/schemas/FsConflict" };
/** The operations, without their prefix: [method, sub, summary, extra]. */
const OPS = [
[
"get",
"/list",
"List a folder (folders first)",
{
parameters: [{ name: "path", in: "query", schema: { type: "string", default: "" } }],
ok: {
description: "The folder.",
content: { "application/json": { schema: { $ref: "#/components/schemas/FsListing" } } },
},
},
],
[
"post",
"/mkdir",
"Make a folder",
{ body: json({ path: { type: "string" } }, ["path"]), status: 201 },
],
[
"post",
"/newfile",
"Make an empty file",
{ body: json({ path: { type: "string" } }, ["path"]), status: 201 },
],
[
"post",
"/rename",
"Rename in place",
{ body: json({ path: { type: "string" }, name: { type: "string" } }, ["path", "name"]) },
],
[
"post",
"/move",
"Move items into a folder",
{ body: json({ paths: pathsArr, dest: { type: "string" }, conflict }, ["paths", "dest"]) },
],
[
"post",
"/copy",
"Copy items into a folder — from here, another workspace (`fromLane`) or a home (`fromHome`) of the same account",
{
body: json(
{
paths: pathsArr,
dest: { type: "string" },
conflict,
fromLane: { type: "integer" },
fromHome: { type: "string" },
},
["paths", "dest"]
),
},
],
[
"post",
"/delete",
"Move items into the account's trash (restorable for 7 days)",
{ body: json({ paths: pathsArr }, ["paths"]) },
],
["get", "/trash", "The trash, newest first (entries older than 7 days are dropped)", {}],
[
"post",
"/trash/restore",
"Put a trash item back where it was",
{ body: json({ id: { type: "string" } }, ["id"]) },
],
[
"post",
"/trash/purge",
"Delete a trash item (or `all`) for good",
{ body: json({ id: { type: "string" } }, ["id"]) },
],
[
"post",
"/upload",
"Upload one file, streamed, making missing sub-folders (≤ 500 MB)",
{
parameters: [
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "conflict", in: "query", schema: conflict },
{
name: "staged",
in: "query",
schema: { type: "string" },
description:
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
},
],
body: {
required: false,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
},
],
[
"get",
"/upload-part",
"How much of a resumable upload arrived",
{
opId: "UploadPartStatus",
note: "`{received, total, done}` for the caller's own upload id (`received: 0, total: null` when unknown). The browser asks this after the dashboard came back, then sends the rest.",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
],
ok: {
description: "`{received, total, done}`",
content: {
"application/json": {
schema: {
type: "object",
properties: {
received: { type: "integer" },
total: { type: "integer", nullable: true },
done: { type: "boolean" },
},
},
},
},
},
},
],
[
"post",
"/upload-part",
"Send one part of a resumable upload (≤ 16 MB a part, 512 MB in all)",
{
opId: "UploadPart",
note: "Raw bytes appended at `offset` to the caller's upload `upload` of `total` bytes, staged on the host (`<data dir>/upload-staging/`) until `/upload` or `/upload-batch` names it with `?staged=`. A part must start where the staged bytes end (409 EOFFSET with `error.received`), one at a time per id (409 EBUSY); a part cut short keeps what arrived. At most 8 unfinished uploads per user (429 ETOOMANY); one untouched for a day is swept.",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
{ name: "offset", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
{ name: "total", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
],
body: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
ok: {
description: "`{received, total}`",
content: {
"application/json": {
schema: {
type: "object",
properties: { received: { type: "integer" }, total: { type: "integer" } },
},
},
},
},
},
],
[
"delete",
"/upload-part",
"Forget a resumable upload's staged bytes",
{
opId: "UploadPartDrop",
parameters: [
{
name: "upload",
in: "query",
required: true,
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
},
],
ok: {
description: "`{dropped: true}`",
content: {
"application/json": {
schema: { type: "object", properties: { dropped: { type: "boolean" } } },
},
},
},
},
],
[
"post",
"/upload-check",
"Before a batch: the names already taken, and whether a batch can go there",
{
note: "`taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member's folder whose `write-as` predates `extract`).",
body: json({ dir: { type: "string", default: "" }, paths: pathsArr }, ["paths"]),
ok: {
description: "`{taken, batch}`",
content: {
"application/json": {
schema: {
type: "object",
properties: { taken: pathsArr, batch: { type: "boolean" } },
},
},
},
},
},
],
[
"post",
"/upload-batch",
"Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)",
{
note: "Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file's bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member's batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body.",
parameters: [
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
{ name: "conflict", in: "query", schema: conflict },
{
name: "staged",
in: "query",
schema: { type: "string" },
description:
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
},
],
body: {
required: false,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
ok: {
description: "`{saved, skipped, paths}`",
content: {
"application/json": {
schema: {
type: "object",
properties: {
saved: { type: "integer" },
skipped: { type: "integer" },
paths: pathsArr,
},
},
},
},
},
},
],
[
"get",
"/zip",
"Files and folders as one streamed zip (≤ 2 GB, 50,000 files)",
{
parameters: [
{
name: "p",
in: "query",
required: true,
schema: { type: "array", items: { type: "string" } },
style: "form",
explode: true,
},
],
ok: {
description: "application/zip",
content: { "application/zip": { schema: { type: "string", format: "binary" } } },
},
},
],
];
function operations(prefix, idParam, opPrefix, note) {
const out = {};
for (const [method, sub, summary, x] of OPS) {
const key = `${prefix}${sub}`;
out[key] = out[key] || {};
out[key][method] = {
tags: ["Files"],
summary,
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
operationId: `${opPrefix}${x.opId || sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
parameters: [idParam, ...(x.parameters || [])],
...(x.body ? { requestBody: x.body } : {}),
responses: { [x.status || 200]: x.ok || { description: "Done." } },
};
}
return out;
}
const paths = {
...operations(
"/api/lanes/{id}/fs",
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
"laneFs",
"On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads)."
),
...operations(
"/api/homes/{who}/fs",
{
name: "who",
in: "path",
required: true,
schema: { type: "string" },
description: "`me`, or another user's name (an admin's read-only view).",
},
"homeFs",
"On a user's home. Every dot entry at the top of the home is hidden and refused."
),
"/api/lanes/{id}/fs/download": undefined,
"/api/homes/{who}/fs/download": {
get: {
tags: ["Files"],
summary: "Download one file of a home",
operationId: "homeFsDownload",
parameters: [
{ name: "who", in: "path", required: true, schema: { type: "string" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
],
responses: { 200: { description: "The file, as a sandboxed attachment." } },
},
},
"/api/homes": {
get: {
tags: ["Files"],
summary: "The homes the caller may open",
description: "`me` (their own), and for an admin every other user's — read-only.",
operationId: "listHomes",
responses: { 200: { description: "{homes: [{id, label, readOnly}]}" } },
},
},
"/api/lanes/clone": {
post: {
tags: ["Git identity"],
summary: "Clone a repository into a new folder and make it a workspace",
description:
"`url`: https:// (no userinfo), ssh:// or user@host:path. Runs as the folder's account with that account's own SSH key or HTTPS login. 409 ECREDENTIALS (`error.auth` names the host) asks for `credentials`; 409 ESSHAUTH: the key was refused; 409 ENOREPO: no such repository.",
operationId: "cloneLane",
requestBody: json(
{
url: { type: "string" },
parent: { type: "string" },
name: { type: "string" },
title: { type: "string" },
tool: { type: "string" },
credentials: {
type: "object",
properties: {
username: { type: "string" },
password: { type: "string" },
remember: { type: "boolean" },
},
},
partial: {
type: "boolean",
description:
"Blobless clone (`--filter=blob:none`): file contents fetched on checkout.",
},
progressToken: {
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.",
},
},
["url", "parent"]
),
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: json({
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: ["Git identity"],
summary: "A clone's place in the queue, then git's progress",
description:
"Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends.",
operationId: "cloneProgress",
parameters: [{ name: "token", in: "path", required: true, schema: { type: "string" } }],
responses: {
200: {
description:
"{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}",
},
404: { description: "ENOTFOUND" },
},
},
},
"/api/me/git": {
get: {
tags: ["Git identity"],
summary: "The caller's own git identity",
description: "Only the public SSH key and `hasToken` are ever returned.",
operationId: "getGitIdentity",
responses: {
200: {
description:
"{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}",
},
},
},
},
"/api/me/git/ssh-key": {
post: {
tags: ["Git identity"],
summary:
"Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it",
operationId: "generateSshKey",
requestBody: json({
replace: { type: "boolean" },
type: { type: "string", enum: ["ed25519", "rsa"] },
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
}),
responses: {
201: { description: "The identity." },
400: { description: "EBADKEYNAME" },
409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" },
},
},
delete: {
tags: ["Git identity"],
summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key",
operationId: "deleteSshKey",
parameters: [
{
name: "type",
in: "query",
required: false,
schema: { type: "string", enum: ["ed25519", "rsa"] },
},
{ name: "id", in: "query", required: false, schema: { type: "string" } },
],
responses: { 200: { description: "The identity." } },
},
},
"/api/me/git/ssh-key/import": {
post: {
tags: ["Git identity"],
summary:
"Import a private key the user already has as a named key; with `sign`, sign commits with it at once",
operationId: "importSshKey",
requestBody: json(
{
privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." },
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
sign: { type: "boolean" },
replace: { type: "boolean" },
},
["privateKey"]
),
responses: {
201: {
description:
"The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).",
},
400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" },
409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" },
},
},
},
"/api/me/git/signing": {
put: {
tags: ["Git identity"],
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
responses: {
200: { description: "The identity; `signing.state` is on, off or foreign." },
400: { description: "ENOKEY" },
409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." },
},
},
},
"/api/me/git/ssh-terminal": {
put: {
tags: ["Git identity"],
summary: "Use the SSH key for git in the account's own terminal (core.sshCommand)",
operationId: "setTerminalSsh",
requestBody: json({ on: { type: "boolean" } }, ["on"]),
responses: {
200: { description: "The identity; `ssh.terminal` is on, off or foreign." },
409: { description: "EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand." },
},
},
},
"/api/me/git/ssh-user": {
put: {
tags: ["Git identity"],
summary:
"An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key",
operationId: "setSshUser",
requestBody: json(
{
host: { type: "string" },
user: { type: "string" },
alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" },
key: { type: "string" },
},
["host", "user"]
),
responses: {
200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." },
400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" },
},
},
},
"/api/me/git/ssh-user/{host}": {
delete: {
tags: ["Git identity"],
summary: "Drop the SSH login of a host or alias (and its key link)",
operationId: "deleteSshUser",
parameters: [
{
name: "host",
in: "path",
required: true,
description: "The host or alias the login was saved under.",
schema: { type: "string" },
},
],
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
},
},
"/api/me/git/https": {
post: {
tags: ["Git identity"],
summary: "Save an HTTPS login (never returned)",
operationId: "saveHttpsLogin",
requestBody: json(
{ host: { type: "string" }, username: { type: "string" }, password: { type: "string" } },
["host", "username", "password"]
),
responses: { 200: { description: "The identity." } },
},
},
"/api/me/git/https/{host}": {
delete: {
tags: ["Git identity"],
summary: "Delete an HTTPS login",
operationId: "deleteHttpsLogin",
parameters: [{ name: "host", in: "path", required: true, schema: { type: "string" } }],
responses: { 200: { description: "The identity." } },
},
},
"/api/me/git/author": {
put: {
tags: ["Git identity"],
summary: "Set the commit author (`git config --global user.name/email`)",
operationId: "setGitAuthor",
requestBody: json({ name: { type: "string" }, email: { type: "string" } }, ["name", "email"]),
responses: { 200: { description: "The identity." }, 400: { description: "EBADAUTHOR" } },
},
},
};
delete paths["/api/lanes/{id}/fs/download"];
module.exports = { tags, schemas, paths };