@loicngr/kobo 2.1.16 → 2.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +52 -0
- package/CHANGELOG.md +8 -0
- package/CONFIGURATION.md +61 -16
- package/README.md +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
- package/dist/server/routes/maintenance.js +32 -0
- package/dist/server/routes/pull-requests.js +2 -1
- package/dist/server/routes/workspaces.js +2 -1
- package/dist/server/services/agent/orchestrator.js +20 -4
- package/dist/server/services/cron-service.js +7 -0
- package/dist/server/services/quota-backoff-service.js +7 -0
- package/dist/server/services/update-maintenance-service.js +44 -0
- package/dist/server/services/wakeup-service.js +21 -3
- package/dist/server/services/workspace-creation-mcp-service.js +16 -0
- package/docker-compose.vps.yml +60 -0
- package/docs/deployment-vps.md +506 -0
- package/docs/getting-started.md +9 -0
- package/package.json +8 -1
- package/scripts/kobo-gh +9 -0
- package/scripts/kobo-github-app-token +190 -0
- package/scripts/kobo-install +392 -0
- package/scripts/kobo-update +499 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-tJmJhmtl.js → ActivityFeed-S3XGw3WW.js} +1 -1
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-Bbrq02Xt.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BJfkAVYp.js → ChangelogPage-C1aiPBoi.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-BfP8APKY.js → ComparisonPanel-DElbn4ac.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-BbtNfo0x.js → CreatePage-Bx58t4YW.js} +1 -1
- package/src/client/dist/pwa/assets/{DashboardPage-DZYsAh5y.js → DashboardPage-D1S3QGp4.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-4Q-GJgyA.js → DiffViewer-BH4X9ru1.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-BfPlfiwU.js → DocumentsPanel-C4AvyZF3.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-DG1Qjqk1.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-ECnbb_rx.js → GitPanel-BP3N_4nh.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-_VXG1Zeo.js → HealthPage-Cm13vxOi.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-DnmUxNk7.css → MainLayout-DoAEgANU.css} +1 -1
- package/src/client/dist/pwa/assets/{MemoryPanel-CVlvGZXZ.js → MemoryPanel-C6tzCv6D.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-SJsnzUAM.js → MemoryProposalList-CzpzIirK.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-DBgGsN-D.js → QBtnDropdown-BnuKPevV.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-DKgaMJ_l.js → QExpansionItem-DjaUhgjt.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-D3OCsq3g.js → QScrollArea-Dpksslru.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-Cb_s_nox.js → QSelect-Dh4CjliE.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-X5ps8Opw.js → QTooltip-CH45NuVs.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Ww_FVlW5.js → SchedulePanel-QAjXuTIN.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-B87WEHf3.js → SearchPage-B1pbwDej.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-BAZLGUyg.js → SettingsPage-CUQefsDh.js} +1 -1
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-B99AJUbG.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DnA1MXwu.js → TerminalPanel-DVomz3oo.js} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-2vFggaur.js → ToolCallItem-BntDHc6p.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BSU3z0aO.js → TouchPan-DwqfsAxH.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DQwS2xdD.js → TourReplayButton-OIavXdUg.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CM1tGDje.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-CQN6EKIn.js → WorkspacePage-CAsY7Hl-.js} +3 -3
- package/src/client/dist/pwa/assets/{browser-DPsupI9Q.js → browser-Cl0d1gbu.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-CbIdt-p9.js → build-path-tree-J3YQeMuV.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-DR68eyF8.js → custom-sounds-D-T5rpgv.js} +1 -1
- package/src/client/dist/pwa/assets/{de-D3Ycvf_F.js → de-C-RtXLqL.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-DvgAtnOj.js → editor.api-BoFiCjBZ.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-nmxfTVQD.js → efforts-Y6XotW97.js} +1 -1
- package/src/client/dist/pwa/assets/{es-TgeS5898.js → es-CBlW91ZK.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-Brn5O4a1.js → expand-template-sfOHWudd.js} +1 -1
- package/src/client/dist/pwa/assets/{format-DfJ3Fv1i.js → format-mWov137C.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-B30bNFhq.js → formatters-B_XsveM0.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-CJafryNb.js → fr-Hfzp_sF5.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-DnZe74tc.js → freemarker2-2pGNakhH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-CmuMfxzO.js → handlebars-Bn2V6Liw.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BD7OJFGX.js → html-BBhkJkf2.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
- package/src/client/dist/pwa/assets/{index-C5fAw9ll.js → index-DCpe-Tfm.js} +3 -3
- package/src/client/dist/pwa/assets/{it-C5mN8Y61.js → it-Bl-v27qg.js} +1 -1
- package/src/client/dist/pwa/assets/{javascript-Y-OrgcmW.js → javascript-Ba0G68U2.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-BqOkKePH.js → liquid-C5VrzHr_.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-D-58x2h0.js → mdx-DrPRiuKb.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-BW4ZMKO6.js → monaco-contributions-qQUY6VS7.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-DJWqzNG-.js → monaco.contribution-Dtq2RsyB.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-BGPRhUa6.js → network-auth-BPJpXK4L.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-DZDzfxph.js → notify-theme-Bl9iRsZB.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-gB5yeZPN.js → project-color-Dzvvmaml.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BkBj1wDC.js → python-Cuf_7umq.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DOOJ_0b8.js → razor-DcVpxFeh.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Bheo43G5.js → typescript-Bjcc3kYC.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-CBpnyV-R.js → use-search-index-status-Bwf40BEI.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-BHNasCvg.js → use-tours-CWafZt-m.js} +1 -1
- package/src/client/dist/pwa/assets/{use-worktree-restore-CgABsWno.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-BnFkigYg.js → workspace-templates-DL_Cs7mD.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-vHt2BD26.js → xml-BmJwqJOz.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BvhLPzP9.js → yaml-DAssfLhS.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +0 -2
- package/src/client/dist/pwa/assets/i18n-BY4JeBjk.js +0 -1
package/AGENTS.md
CHANGED
|
@@ -41,6 +41,7 @@ npm run typecheck:tests # type check backend test fixtures and tests
|
|
|
41
41
|
npm test # backend vitest suite
|
|
42
42
|
(cd src/client && npm test) # client stores, utilities, composables and components
|
|
43
43
|
npm run test:all # backend + client suites
|
|
44
|
+
npm run test:deployment # Python 3.10+ / OpenSSL tooling tests; no Docker/network/production data
|
|
44
45
|
make ci # install, audit, lint, type checks, build, both suites
|
|
45
46
|
npm run test:watch # vitest in watch mode
|
|
46
47
|
|
|
@@ -248,6 +249,57 @@ Two emit flavors in `websocket-service.ts`:
|
|
|
248
249
|
|
|
249
250
|
## External integrations
|
|
250
251
|
|
|
252
|
+
### VPS deployment tooling
|
|
253
|
+
|
|
254
|
+
`docker-compose.vps.yml` is the standalone fresh-instance example for an existing
|
|
255
|
+
Traefik network. It persists `/root`, mounts project paths identically on host and
|
|
256
|
+
container, and leaves the Docker socket opt-in. `docs/deployment-vps.md` owns the
|
|
257
|
+
operator walkthrough; `CONFIGURATION.md` compares all Compose topologies.
|
|
258
|
+
|
|
259
|
+
`scripts/kobo-install` guides a fresh installation; it does not adopt existing
|
|
260
|
+
unmanaged stacks. Preserve its read-only `--check`, explicit Docker-socket opt-in,
|
|
261
|
+
managed-file fingerprints, shared updater lock, and durable start phases. Completed
|
|
262
|
+
reruns must never restart containers or replace credentials. Only the updater's
|
|
263
|
+
image pin may differ from the managed Compose fingerprint. Interrupted starts
|
|
264
|
+
require diagnosis instead of automatic recreation. This tool reuses the updater's
|
|
265
|
+
release resolution/build helpers; no separate public image registry is assumed.
|
|
266
|
+
|
|
267
|
+
Optional GitHub App authentication uses `scripts/kobo-gh` and
|
|
268
|
+
`scripts/kobo-github-app-token`, mounted read-only with the user's RSA key. The
|
|
269
|
+
wrapper honors explicit `GH_TOKEN` / `GITHUB_TOKEN`, otherwise obtains a renewable
|
|
270
|
+
installation token from the private, locked cache. Keep JWTs, PEM contents and
|
|
271
|
+
tokens out of logs/errors. HTTPS Git uses the same wrapper via a github.com-scoped
|
|
272
|
+
credential helper; commit author identity is configured separately. Never bake
|
|
273
|
+
credentials into an image or add web settings for these host-managed secrets.
|
|
274
|
+
|
|
275
|
+
`scripts/kobo-update` is a Python 3.10+ operator tool for this layout, separate
|
|
276
|
+
from the backend's update notifications. Preserve explicit invocation, idle checks,
|
|
277
|
+
unique image tags, private stopped-volume backups, migration tests on a copy,
|
|
278
|
+
and durable failure records. The authenticated `/api/maintenance/update` reservation
|
|
279
|
+
checks runtime ownership and closes agent admission synchronously before shutdown.
|
|
280
|
+
Track in-flight workspace creations and PR checkouts through all asynchronous
|
|
281
|
+
steps and rollback. Maintenance and creation admission must exclude each other
|
|
282
|
+
synchronously; rejecting only the final agent start is too late. Direct HTTP and
|
|
283
|
+
MCP creation paths share this guard.
|
|
284
|
+
Scheduled cron/wakeup/quota deliveries defer without consuming persisted intent;
|
|
285
|
+
valid pending wakeups get `retry_at=target_at` before successful reservation (only
|
|
286
|
+
when `retry_at` was null), so long maintenance does not expire them at boot. Keep
|
|
287
|
+
the requested deadline/prompt and existing retry metadata; do not revive already
|
|
288
|
+
stale, never-attempted wakeups. New wakeups scheduled during maintenance get the
|
|
289
|
+
same durable deferral marker.
|
|
290
|
+
Replaced timers must invalidate deferred callbacks. Legacy runtimes without this
|
|
291
|
+
protocol refuse automatic deployment while `--prepare` remains available. Failed
|
|
292
|
+
pre-stop requests release their reservation; uncertain stops must be confirmed
|
|
293
|
+
before restarting. A crashed updater may require the documented manual recovery. Never automatically run an old image against a
|
|
294
|
+
potentially migrated database or restore snapshots after new work may have run.
|
|
295
|
+
No deployment-specific hostname, App credentials or private source paths belong
|
|
296
|
+
in this script. Its deterministic tests run through `npm run test:deployment`,
|
|
297
|
+
`make ci` and the PR workflow; live deployment is not a CI test.
|
|
298
|
+
Installer/updater Compose commands explicitly select the stack's `.env` and strip
|
|
299
|
+
the template's Kobo/Traefik interpolation variables plus `COMPOSE_*` from a copy
|
|
300
|
+
of the child environment. Apply the same policy to captured and logged commands;
|
|
301
|
+
never mutate the parent environment or lose Docker/forge authentication settings.
|
|
302
|
+
|
|
251
303
|
### Forge providers
|
|
252
304
|
|
|
253
305
|
`src/server/services/forge/` implements `ForgeProvider` for `github` (`gh`), `gitlab` (`glab`), `bitbucket-community` (`bkt`), and `none` (disables PR/MR features). `getForgeProvider(id)` resolves a provider; `resolveForge(projectPath)` first reads the explicit project setting, then classifies the `origin` URL by `github.com`, `gitlab`, or `bitbucket`, falling back to `none`. Successful remote reads are cached for five minutes. PR routes and the watcher use the resolved provider. **Kōbō ships no forge credentials**: users authenticate `gh`/`glab` themselves; Bitbucket uses the email and API token entered in Settings, passed to `bkt` and excluded from settings exports. Missing or unauthenticated CLIs disable PR/MR actions with an explanation.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 2.1.18
|
|
8
|
+
|
|
9
|
+
- fix(tests): resolve deployment paths on macos
|
|
10
|
+
|
|
11
|
+
## 2.1.17
|
|
12
|
+
|
|
13
|
+
- feat(kobo): add VPS tooling and touch drawer resizing
|
|
14
|
+
|
|
7
15
|
## 2.1.16
|
|
8
16
|
|
|
9
17
|
- feat(workspaces): add group messaging and expand mcp lifecycle tools
|
package/CONFIGURATION.md
CHANGED
|
@@ -35,6 +35,8 @@ Reference for Kōbō configuration and external integrations. The tables cover t
|
|
|
35
35
|
- [Quick local test — docker-compose.local.yml](#quick-local-test--docker-composelocalyml)
|
|
36
36
|
- [Rehearsing the reverse-proxy stack — docker-compose.local-traefikyml](#rehearsing-the-reverse-proxy-stack--docker-composelocal-traefikyml)
|
|
37
37
|
- [Real deployment — docker-compose.exampleyml](#real-deployment--docker-composeexampleyml)
|
|
38
|
+
- [VPS with existing Traefik](#vps-with-existing-traefik)
|
|
39
|
+
- [Docker updates and backups](#docker-updates-and-backups)
|
|
38
40
|
- [Troubleshooting](#troubleshooting)
|
|
39
41
|
- [Agent runtimes](#agent-runtimes)
|
|
40
42
|
- [Claude Code](#claude-code)
|
|
@@ -255,6 +257,12 @@ When working from source, `npm run install-all` installs the root, client, and
|
|
|
255
257
|
build-only Workbox dependencies and is required by `npm run build`, but not by
|
|
256
258
|
the normal development client.
|
|
257
259
|
|
|
260
|
+
On touch screens, drag the inner edge of either side panel to resize it. The
|
|
261
|
+
horizontal divider inside the right panel can also be dragged. Widths and the
|
|
262
|
+
split position are remembered in this browser; on small screens, a strip of the
|
|
263
|
+
chat remains visible so you can tap outside a panel to close it. Sidebar actions
|
|
264
|
+
wrap onto additional lines when needed, keeping Settings and Create accessible.
|
|
265
|
+
|
|
258
266
|
## Custom change-source-branch script
|
|
259
267
|
|
|
260
268
|
The **Change source branch** action uses the effective `changeSourceBranchScript`:
|
|
@@ -1108,27 +1116,31 @@ For the concrete Docker setup (which compose file, which env vars, which volumes
|
|
|
1108
1116
|
|
|
1109
1117
|
## Docker deployment
|
|
1110
1118
|
|
|
1111
|
-
Kōbō ships an official `Dockerfile` and
|
|
1112
|
-
|
|
1119
|
+
Kōbō ships an official `Dockerfile` and four Compose examples. Choose whether you
|
|
1120
|
+
want to reuse host credentials or start an independent instance, and whether
|
|
1121
|
+
Traefik already exists. Build the image from a source checkout before starting.
|
|
1113
1122
|
|
|
1114
1123
|
### Choosing a compose file
|
|
1115
1124
|
|
|
1116
1125
|
| File | Topology | Use it for |
|
|
1117
1126
|
|---|---|---|
|
|
1118
|
-
| [`docker-compose.local.yml`](./docker-compose.local.yml) | Kōbō only, port `3000` published directly. No Traefik,
|
|
1127
|
+
| [`docker-compose.local.yml`](./docker-compose.local.yml) | Kōbō only, port `3000` published directly. No Traefik, TLS, published SSH port or Docker socket. | Trying Kōbō out or poking at a change locally — the fastest path to a running instance. |
|
|
1119
1128
|
| [`docker-compose.local-traefik.yml`](./docker-compose.local-traefik.yml) | Traefik in front, Kōbō never on a published port, `kobo.localhost` (auto-resolves to `127.0.0.1`, no `/etc/hosts` editing). | Rehearsing the exact reverse-proxy topology/auth/mount setup locally, before touching a real VPS. |
|
|
1129
|
+
| [`docker-compose.vps.yml`](./docker-compose.vps.yml) | Kōbō only, existing external Traefik network, persistent `/root`, no published ports or host credential mounts. | A fresh, independent remote instance; follow the [VPS guide](./docs/deployment-vps.md). |
|
|
1120
1130
|
| [`docker-compose.example.yml`](./docker-compose.example.yml) | Traefik + Let's Encrypt (real domain, real TLS), SSH access, optional Docker-socket passthrough. | The reference for an actual production deployment (VPS behind a reverse proxy). |
|
|
1121
1131
|
|
|
1122
|
-
All
|
|
1123
|
-
|
|
1124
|
-
|
|
1132
|
+
All four use the same underlying image (`docker build -t kobo:local .` from the
|
|
1133
|
+
repo root). The original three mount provider/Git configuration from the host;
|
|
1134
|
+
the VPS example keeps independent credentials in a persistent container home.
|
|
1135
|
+
The VPS example overrides the entrypoint to start Node directly; the original
|
|
1136
|
+
files use the image entrypoint, which starts `sshd` even when port 22 is not published.
|
|
1125
1137
|
|
|
1126
1138
|
### The image
|
|
1127
1139
|
|
|
1128
1140
|
Multi-stage `Dockerfile`, `node:24.19.0-slim` base:
|
|
1129
1141
|
|
|
1130
1142
|
- **Build stage**: installs root, client, and PWA deps, then runs `npm run build` (client via Quasar, server via `tsc`).
|
|
1131
|
-
- **Runtime stage**: production deps only, plus OS tooling the agents and forge CLIs need — `git`, `openssh-client` + `openssh-server`, `ripgrep`, `fd-find`, `jq`, `curl`, Python + build tools, the Docker CLI (talks to a mounted host socket, see [Volumes](#volumes)), `gh`, `glab`, `tmux`, `vim`/`nano`, and more for interactive shell use.
|
|
1143
|
+
- **Runtime stage**: production deps only, plus OS tooling the agents and forge CLIs need — `git`, `openssh-client` + `openssh-server`, `ripgrep`, `fd-find`, `jq`, `curl`, Python + build tools, the Docker CLI and Compose plugin (`docker compose`, talks to a mounted host socket, see [Volumes](#volumes)), `gh`, `glab`, `tmux`, `vim`/`nano`, and more for interactive shell use.
|
|
1132
1144
|
- **Global CLIs**: `@anthropic-ai/claude-code` and `@openai/codex` are installed globally for interactive login (`claude /login`, `codex login` over SSH) — separate from Kōbō's own bundled SDK/subprocess usage, which needs no global install.
|
|
1133
1145
|
- **`git config --system --add safe.directory '*'`**: the container runs as root, but bind-mounted project repos keep the *host* user's UID on disk. Git's ownership check (CVE-2022-24765 mitigation) refuses to touch a repo it doesn't "own" unless told otherwise. This line pre-trusts every mounted path — fine for a single-user dev container, not something you'd do on a multi-tenant host.
|
|
1134
1146
|
- **`HEALTHCHECK`**: `curl -f http://localhost:$SERVER_PORT/api/health` every 30s. `/api/health` is exempt from the network-access token gate unconditionally (even with `networkAccessBehindProxy` on) — Docker's `HEALTHCHECK` process has no way to supply the runtime-generated token, and without the exemption a "behind a reverse proxy" container never reports healthy, which makes Traefik's Docker provider filter it out of its router table entirely (Traefik drops unhealthy/starting containers from routing).
|
|
@@ -1142,11 +1154,13 @@ Beyond the [general environment variables](#environment-variables), these matter
|
|
|
1142
1154
|
|
|
1143
1155
|
| Variable | Where it's set | Purpose |
|
|
1144
1156
|
|---|---|---|
|
|
1145
|
-
| `KOBO_HOME` | all
|
|
1146
|
-
| `KOBO_NETWORK_ACCESS_ENABLED` | all
|
|
1147
|
-
| `KOBO_NETWORK_ACCESS_BEHIND_PROXY` | `local-traefik.yml`, `example.yml` | Disables the loopback-trust bypass entirely — needed whenever a reverse proxy sits in front, since every request now looks like it comes from the proxy. Not set in `local.yml`, which publishes Kōbō's port directly with no proxy in between. |
|
|
1148
|
-
| `GH_TOKEN` / `GITLAB_TOKEN` | `example.yml` (bring your own via `.env`) |
|
|
1149
|
-
| `KOBO_PROJECTS_DIR` |
|
|
1157
|
+
| `KOBO_HOME` | all four Compose files | Kōbō state at `/root/.config/kobo`, inside a named volume; survives `docker compose down` without `-v`. The VPS example persists all of `/root`. |
|
|
1158
|
+
| `KOBO_NETWORK_ACCESS_ENABLED` | all four | Binds Kōbō to `0.0.0.0` inside the container at boot — required, since a Docker-forwarded or Traefik-proxied connection never looks like true loopback from Kōbō's point of view, even locally. Without it the container's `127.0.0.1`-only default blocks every external route. |
|
|
1159
|
+
| `KOBO_NETWORK_ACCESS_BEHIND_PROXY` | `local-traefik.yml`, `example.yml`, `vps.yml` | Disables the loopback-trust bypass entirely — needed whenever a reverse proxy sits in front, since every request now looks like it comes from the proxy. Not set in `local.yml`, which publishes Kōbō's port directly with no proxy in between. |
|
|
1160
|
+
| `GH_TOKEN` / `GITLAB_TOKEN` | `example.yml` (bring your own via `.env`) | Optional non-interactive auth for `gh`/`glab` inside the container. A CLI browser/device flow can also be completed from your PC. Expiring GitHub App tokens need a [renewable helper](./docs/deployment-vps.md#github-app-bot-authentication), not a static token in `.env`. |
|
|
1161
|
+
| `KOBO_PROJECTS_DIR` | Compose interpolation on the host | Original three: source for `/projects`, default `./kobo-projects`. VPS: required existing absolute directory, mounted at the **same path** in the container for host-Docker bind mounts. |
|
|
1162
|
+
| `KOBO_DOMAIN` | `vps.yml`, Compose interpolation | Public hostname used in the Traefik rule and `KOBO_NETWORK_ACCESS_PROXY_HOST`; required, without `https://`. |
|
|
1163
|
+
| `TRAEFIK_NETWORK` / `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER` | `vps.yml`, Compose interpolation | Existing Traefik names; defaults `web` / `websecure` / `letsencrypt`. Adapt to your proxy. |
|
|
1150
1164
|
|
|
1151
1165
|
Both `KOBO_NETWORK_ACCESS_ENABLED` and `KOBO_NETWORK_ACCESS_BEHIND_PROXY` are read once at boot —
|
|
1152
1166
|
this is what lets the container come up fully configured with no manual Settings-UI step (there's
|
|
@@ -1157,14 +1171,15 @@ retrieved once — see [Token / login flow](#token--login-flow).
|
|
|
1157
1171
|
|
|
1158
1172
|
| Mount | RO/RW | Purpose |
|
|
1159
1173
|
|---|---|---|
|
|
1174
|
+
| `kobo-home:/root` (`vps.yml`, named volume) | RW | Independent Kōbō, Claude/Codex, Git and forge state. Authenticate inside the container; no PC credential files are required. |
|
|
1160
1175
|
| `<name>-data:/root/.config/kobo` (named volume) | RW | Kōbō's own state — SQLite DB, `settings.json`, etc. Named (not bind-mounted) so it survives `docker compose down` without `-v`, and so it can be deliberately **shared** across compose files by reusing the same volume name (see the two local files, both `kobo-test-data`). |
|
|
1161
|
-
| `${KOBO_PROJECTS_DIR:-./kobo-projects}:/projects` | RW |
|
|
1176
|
+
| `${KOBO_PROJECTS_DIR:-./kobo-projects}:/projects` | RW | Your project repositories. Ensure the configured worktree directory is also persistent. For project Docker bind mounts, use the VPS example’s identical host/container paths; see the [path setup](./docs/deployment-vps.md#optional-project-docker-environments). |
|
|
1162
1177
|
| `${HOME}/.ssh:/root/.ssh` | RO | Git SSH auth — needed to push/pull over SSH remotes. |
|
|
1163
1178
|
| `${HOME}/.gitconfig:/root/.gitconfig` | RO | Commit author identity. Without it, agent-authored commits fail with "please tell me who you are". |
|
|
1164
1179
|
| `${HOME}/.claude.json:/root/.claude.json` | RW | Claude Code auth + MCP server registrations. **Must already exist as a file on the host** (run `claude /login` locally first if it doesn't) — Docker silently creates an empty *directory* instead of erroring on a missing bind-mount source, which breaks auth in a confusing way. |
|
|
1165
1180
|
| `${HOME}/.claude:/root/.claude` | RW | Claude Code's full state directory (skills/plugins cache, session transcripts, memory) — Claude Code writes into this tree during normal operation. |
|
|
1166
1181
|
| `${HOME}/.codex:/root/.codex` | RW | Codex's full state directory (`auth.json`, `config.toml`). Harmless to mount even if you only use Claude Code. |
|
|
1167
|
-
| `/var/run/docker.sock:/var/run/docker.sock` | RW, `example.yml`
|
|
1182
|
+
| `/var/run/docker.sock:/var/run/docker.sock` | RW, enabled in `example.yml`, opt-in in `vps.yml` | Lets the dev-server panel start/stop/tail logs for your projects' own `docker compose` stacks. **DANGER**: grants the container root-equivalent control over the host — anyone who can run a command inside `kobo` can spawn a privileged container that mounts the host filesystem. Remove this line if you don't need the dev-server panel. |
|
|
1168
1183
|
|
|
1169
1184
|
### Ports
|
|
1170
1185
|
|
|
@@ -1175,12 +1190,12 @@ retrieved once — see [Token / login flow](#token--login-flow).
|
|
|
1175
1190
|
| `443` | `example.yml` | Traefik's TLS entrypoint (Let's Encrypt via the `le` cert resolver), routes your real domain. |
|
|
1176
1191
|
| `2222 → 22` (published) | `example.yml` only | SSH into the container (`ssh -p 2222 root@<host>`), key-based auth only via the same mounted `~/.ssh`, no password login. The one exception to "no published ports" — Traefik proxies HTTP/WS, not raw TCP, so SSH needs its own path. Rebuilding the image regenerates the SSH host keys, so expect a one-time "host key changed" warning from your SSH client after a rebuild. |
|
|
1177
1192
|
|
|
1178
|
-
Kōbō itself is **never** published directly in the
|
|
1193
|
+
Kōbō itself is **never** published directly in the three Traefik-fronted files — only Traefik is
|
|
1179
1194
|
reachable, matching the real production posture.
|
|
1180
1195
|
|
|
1181
1196
|
### Token / login flow
|
|
1182
1197
|
|
|
1183
|
-
All
|
|
1198
|
+
All four files pre-enable network access via env vars, so there's no interactive Settings-UI step.
|
|
1184
1199
|
Retrieve the auto-generated token from the container's boot log, then bookmark the token-bearing URL:
|
|
1185
1200
|
|
|
1186
1201
|
```bash
|
|
@@ -1264,6 +1279,36 @@ Open `https://kobo.example.com/?token=<token>`. SSH access: `ssh -p 2222 root@<h
|
|
|
1264
1279
|
only). See [Ports](#ports) and [Volumes](#volumes) above for the full rationale on the Docker socket
|
|
1265
1280
|
and SSH tradeoffs before deploying this to a real machine.
|
|
1266
1281
|
|
|
1282
|
+
### VPS with existing Traefik
|
|
1283
|
+
|
|
1284
|
+
Use [`docker-compose.vps.yml`](./docker-compose.vps.yml) and the
|
|
1285
|
+
[step-by-step VPS guide](./docs/deployment-vps.md). This variant runs only Kōbō,
|
|
1286
|
+
keeps agent credentials in its own persistent home and joins an existing proxy
|
|
1287
|
+
network. Your browser's project picker refers to paths on the VPS/container,
|
|
1288
|
+
not your PC. The guide covers provider login, GitHub App bots, Git HTTPS helpers
|
|
1289
|
+
and the host/container path requirements for project Docker environments.
|
|
1290
|
+
|
|
1291
|
+
For a fresh stack, [`scripts/kobo-install`](./scripts/kobo-install) guides domain,
|
|
1292
|
+
project path, existing Traefik settings and optional GitHub App credentials.
|
|
1293
|
+
`--check` performs read-only checks; `--prepare` builds without starting. Its
|
|
1294
|
+
private manifest protects managed files from accidental replacement. GitHub App
|
|
1295
|
+
configuration stays in private host files with read-only container mounts, outside
|
|
1296
|
+
web settings. See the [guided installation](./docs/deployment-vps.md#guided-installation)
|
|
1297
|
+
and [manual bot configuration](./docs/deployment-vps.md#github-app-bot-authentication).
|
|
1298
|
+
|
|
1299
|
+
### Docker updates and backups
|
|
1300
|
+
|
|
1301
|
+
For the independent VPS layout, [`scripts/kobo-update`](./scripts/kobo-update)
|
|
1302
|
+
accepts a stable version or defaults to npm `latest`. It has `--check`, `--prepare`
|
|
1303
|
+
and `--directory` options. See [updater usage and recovery](./docs/deployment-vps.md#update-with-kobo-update).
|
|
1304
|
+
This is an operator-invoked script; the in-app update banner does not install releases.
|
|
1305
|
+
|
|
1306
|
+
A source-built image needs rebuilding for a new release; `docker compose pull`
|
|
1307
|
+
alone cannot update an image that only exists locally. Recreate the service
|
|
1308
|
+
with the same persistent volumes and credential mounts after backing up its
|
|
1309
|
+
state. Preserve repository/worktree data and project databases separately.
|
|
1310
|
+
An old image alone is not a complete rollback after database migrations.
|
|
1311
|
+
|
|
1267
1312
|
### Troubleshooting
|
|
1268
1313
|
|
|
1269
1314
|
- **`fatal: detected dubious ownership in repository at '/projects/<name>'`** — only happens if you
|
package/README.md
CHANGED
|
@@ -116,7 +116,9 @@ for credential priority and native-configuration scope.
|
|
|
116
116
|
|
|
117
117
|
### Docker
|
|
118
118
|
|
|
119
|
-
An official `Dockerfile` and
|
|
119
|
+
An official `Dockerfile` and four Compose examples ship in this repository: a local test stack, a local Traefik rehearsal, a deployment including Traefik, and a fresh VPS instance behind an existing Traefik.
|
|
120
|
+
|
|
121
|
+
**Working remotely on your own VPS?** The [VPS deployment guide](./docs/deployment-vps.md) includes a guided `kobo-install` installer, optional renewable GitHub App authentication, persistent agent logins and Docker updates. Your browser runs on your PC; repositories and agents run on the VPS. See [`CONFIGURATION.md`](./CONFIGURATION.md#docker-deployment) for the Compose comparison and shared settings.
|
|
120
122
|
|
|
121
123
|
### Network access
|
|
122
124
|
|
|
@@ -164,4 +166,4 @@ GPL-3.0-or-later. See [`LICENSE`](./LICENSE).
|
|
|
164
166
|
|
|
165
167
|
Report reproducible bugs and feature requests in [GitHub Issues](https://github.com/loicngr/Kobo/issues). Read [Contributing](./CONTRIBUTING.md) and the [Code of conduct](./CODE_OF_CONDUCT.md) before participating. Vulnerabilities belong in the private channel described in [Security](./SECURITY.md), not public issues. Support is maintained on a best-effort basis; no response-time commitment is implied.
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
Linux is locally exercised; macOS CI, WSL and real-provider beta sessions require their own evidence. See [third-party notices](./THIRD_PARTY_NOTICES.md) for redistributed assets.
|
package/dist/server/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import gitRouter from './routes/git.js';
|
|
|
20
20
|
import healthRouter from './routes/health.js';
|
|
21
21
|
import imagesRouter from './routes/images.js';
|
|
22
22
|
import integrationsRouter from './routes/integrations.js';
|
|
23
|
+
import maintenanceRouter from './routes/maintenance.js';
|
|
23
24
|
import mcpRouter from './routes/mcp.js';
|
|
24
25
|
import memoryRouter from './routes/memory.js';
|
|
25
26
|
import { migrationRouter } from './routes/migration.js';
|
|
@@ -178,6 +179,7 @@ app.route('/api/workspaces', documentsRouter);
|
|
|
178
179
|
app.route('/api/workspaces', exportRouter);
|
|
179
180
|
app.route('/api/search', searchRouter);
|
|
180
181
|
app.route('/api/health', healthRouter);
|
|
182
|
+
app.route('/api/maintenance', maintenanceRouter);
|
|
181
183
|
app.route('/api/engines', enginesRouter);
|
|
182
184
|
app.route('/api/environment', environmentRouter);
|
|
183
185
|
app.route('/api/integrations', integrationsRouter);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { beginWorkspaceCreation } from '../services/update-maintenance-service.js';
|
|
2
|
+
/** Admission remains held across every await and the handler's rollback paths. */
|
|
3
|
+
export const workspaceCreationMaintenance = async (c, next) => {
|
|
4
|
+
const release = beginWorkspaceCreation();
|
|
5
|
+
if (!release)
|
|
6
|
+
return c.json({ code: 'update-maintenance', error: 'Workspace creation is unavailable during update maintenance' }, 503);
|
|
7
|
+
try {
|
|
8
|
+
await next();
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
release();
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { isIdleForUpdate, resumeAfterUpdateMaintenance } from '../services/agent/orchestrator.js';
|
|
3
|
+
import { acquireUpdateMaintenance, isUpdateMaintenanceActive, releaseUpdateMaintenance, } from '../services/update-maintenance-service.js';
|
|
4
|
+
import { preserveForUpdateMaintenance } from '../services/wakeup-service.js';
|
|
5
|
+
/** Protected by the same network-token/Host/Origin middleware as other APIs. */
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.get('/update', (c) => c.json({ protocol: 1, active: isUpdateMaintenanceActive() }));
|
|
8
|
+
app.on(['POST', 'DELETE'], '/update', async (c) => {
|
|
9
|
+
const body = await c.req.json().catch(() => null);
|
|
10
|
+
if (!body || typeof body.owner !== 'string' || !/^[a-f0-9]{32}$/.test(body.owner)) {
|
|
11
|
+
return c.json({ error: 'Expected a 32-character update reservation identifier' }, 400);
|
|
12
|
+
}
|
|
13
|
+
if (c.req.method === 'DELETE') {
|
|
14
|
+
if (!releaseUpdateMaintenance(body.owner))
|
|
15
|
+
return c.json({ error: 'Update reservation belongs to another owner' }, 409);
|
|
16
|
+
resumeAfterUpdateMaintenance();
|
|
17
|
+
return c.json({ protocol: 1, active: false });
|
|
18
|
+
}
|
|
19
|
+
// Both operations are synchronous: no agent can start between this check and reservation.
|
|
20
|
+
const result = acquireUpdateMaintenance(body.owner, () => {
|
|
21
|
+
if (!isIdleForUpdate())
|
|
22
|
+
return false;
|
|
23
|
+
// Commit deferral before acknowledging admission. Future wakeups may become
|
|
24
|
+
// overdue while the stopped volume is backed up and migrated.
|
|
25
|
+
preserveForUpdateMaintenance();
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
if (result !== 'acquired')
|
|
29
|
+
return c.json({ error: result === 'busy' ? 'Kōbō is busy' : 'Update already reserved' }, 409);
|
|
30
|
+
return c.json({ protocol: 1, active: true });
|
|
31
|
+
});
|
|
32
|
+
export default app;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
+
import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
|
|
3
4
|
import { getForgeProvider } from '../services/forge/registry.js';
|
|
4
5
|
import { resolveForge } from '../services/forge/resolve.js';
|
|
5
6
|
import * as prCheckout from '../services/pr-checkout-service.js';
|
|
@@ -86,7 +87,7 @@ app.post('/diagnose', async (c) => {
|
|
|
86
87
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
87
88
|
}
|
|
88
89
|
});
|
|
89
|
-
app.post('/resolve', async (c) => {
|
|
90
|
+
app.post('/resolve', workspaceCreationMaintenance, async (c) => {
|
|
90
91
|
try {
|
|
91
92
|
const body = await c.req
|
|
92
93
|
.json()
|
|
@@ -19,6 +19,7 @@ import { MAX_ATTACHMENT_REQUEST_BYTES } from '../../shared/attachments.js';
|
|
|
19
19
|
import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, buildGroomingIntro } from '../../shared/auto-loop-prompts.js';
|
|
20
20
|
import { getDb } from '../db/index.js';
|
|
21
21
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
22
|
+
import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
|
|
22
23
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
23
24
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
24
25
|
import * as archiveScriptService from '../services/archive-script-service.js';
|
|
@@ -452,7 +453,7 @@ const creationBodyLimit = bodyLimit({
|
|
|
452
453
|
maxSize: MAX_ATTACHMENT_REQUEST_BYTES,
|
|
453
454
|
onError: (c) => c.json({ error: 'Workspace creation request is too large', step: 'validate' }, 413),
|
|
454
455
|
});
|
|
455
|
-
app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
456
|
+
app.post('/', workspaceCreationMaintenance, migrationGuard, creationBodyLimit, async (c) => {
|
|
456
457
|
// Declared outside the try so the outermost catch (any unforeseen throw,
|
|
457
458
|
// including a malformed request body) can still name a step instead of
|
|
458
459
|
// leaving the create-progress channel silent. Updated in lockstep with
|
|
@@ -20,6 +20,7 @@ import { buildReviewReturnPrompt, claimReviewReturn, completeReviewReturn, getRe
|
|
|
20
20
|
import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
|
|
21
21
|
import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
|
|
22
22
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
23
|
+
import { isUpdateMaintenanceActive } from '../update-maintenance-service.js';
|
|
23
24
|
import { refreshNow } from '../usage/poller.js';
|
|
24
25
|
import * as wakeupService from '../wakeup-service.js';
|
|
25
26
|
import { emit, emitEphemeral } from '../websocket-service.js';
|
|
@@ -67,7 +68,7 @@ let shuttingDown = false;
|
|
|
67
68
|
let capacityTimer;
|
|
68
69
|
const stoppedCapacityOwners = new Set();
|
|
69
70
|
function notifyCapacityAvailable(stoppedWorkspaceId) {
|
|
70
|
-
if (
|
|
71
|
+
if (isShuttingDown())
|
|
71
72
|
return;
|
|
72
73
|
stoppedCapacityOwners.add(stoppedWorkspaceId);
|
|
73
74
|
if (capacityTimer)
|
|
@@ -77,7 +78,7 @@ function notifyCapacityAvailable(stoppedWorkspaceId) {
|
|
|
77
78
|
capacityTimer = undefined;
|
|
78
79
|
const excluded = new Set(stoppedCapacityOwners);
|
|
79
80
|
stoppedCapacityOwners.clear();
|
|
80
|
-
if (
|
|
81
|
+
if (isShuttingDown())
|
|
81
82
|
return;
|
|
82
83
|
try {
|
|
83
84
|
resumePendingReviewReturns();
|
|
@@ -1365,7 +1366,7 @@ function revokeControllerMemoryCapability(controller) {
|
|
|
1365
1366
|
const reviewReturnDispatchOwners = new Map();
|
|
1366
1367
|
/** Resume durable review work only after the previous runtime has closed and admission allows it. */
|
|
1367
1368
|
export function resumePendingReviewReturns(onlyWorkspaceId) {
|
|
1368
|
-
if (
|
|
1369
|
+
if (isShuttingDown())
|
|
1369
1370
|
return;
|
|
1370
1371
|
for (const pending of listReviewReturns()) {
|
|
1371
1372
|
const id = pending.workspaceId;
|
|
@@ -1465,6 +1466,8 @@ export function cancelReviewReturn(workspaceId) {
|
|
|
1465
1466
|
* query the controller via `_getControllers()` for tests.
|
|
1466
1467
|
*/
|
|
1467
1468
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
|
|
1469
|
+
if (isUpdateMaintenanceActive())
|
|
1470
|
+
throw new Error('Cannot start an agent during update maintenance');
|
|
1468
1471
|
if (shuttingDown)
|
|
1469
1472
|
throw new Error('Cannot start an agent while the server is shutting down');
|
|
1470
1473
|
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
@@ -1914,7 +1917,20 @@ export async function stopAllAgents(timeoutMs = 3_000) {
|
|
|
1914
1917
|
}
|
|
1915
1918
|
/** Automatic continuations must preserve their durable intent during teardown. */
|
|
1916
1919
|
export function isShuttingDown() {
|
|
1917
|
-
return shuttingDown;
|
|
1920
|
+
return shuttingDown || isUpdateMaintenanceActive();
|
|
1921
|
+
}
|
|
1922
|
+
/** Used synchronously by update reservation; include runtimes still closing and pending replacements. */
|
|
1923
|
+
export function isIdleForUpdate() {
|
|
1924
|
+
if (shuttingDown || controllers.size || pendingStarts.size)
|
|
1925
|
+
return false;
|
|
1926
|
+
const db = getDb();
|
|
1927
|
+
return (!db.prepare("SELECT 1 FROM agent_sessions WHERE status = 'running' LIMIT 1").get() &&
|
|
1928
|
+
!db
|
|
1929
|
+
.prepare("SELECT 1 FROM workspaces WHERE status IN ('extracting','brainstorming','executing','compacting') LIMIT 1")
|
|
1930
|
+
.get());
|
|
1931
|
+
}
|
|
1932
|
+
export function resumeAfterUpdateMaintenance() {
|
|
1933
|
+
notifyCapacityAvailable('');
|
|
1918
1934
|
}
|
|
1919
1935
|
/** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
|
|
1920
1936
|
export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
@@ -7,6 +7,7 @@ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
|
7
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
8
8
|
import * as autoLoopService from './auto-loop-service.js';
|
|
9
9
|
import * as settingsService from './settings-service.js';
|
|
10
|
+
import { deferForUpdateMaintenance } from './update-maintenance-service.js';
|
|
10
11
|
import { emitEphemeral } from './websocket-service.js';
|
|
11
12
|
export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
|
|
12
13
|
// Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
|
|
@@ -185,6 +186,12 @@ export function listAll() {
|
|
|
185
186
|
function fireOrSkip(id) {
|
|
186
187
|
if (suspended)
|
|
187
188
|
return;
|
|
189
|
+
const pendingTimer = timers.get(id);
|
|
190
|
+
if (deferForUpdateMaintenance(`cron:${id}`, () => {
|
|
191
|
+
if (timers.get(id) === pendingTimer)
|
|
192
|
+
fireOrSkip(id);
|
|
193
|
+
}))
|
|
194
|
+
return;
|
|
188
195
|
try {
|
|
189
196
|
timers.delete(id);
|
|
190
197
|
const db = getDb();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getDb } from '../db/index.js';
|
|
2
|
+
import { deferForUpdateMaintenance } from './update-maintenance-service.js';
|
|
2
3
|
import { emitEphemeral } from './websocket-service.js';
|
|
3
4
|
import { getWorkspace } from './workspace-service.js';
|
|
4
5
|
const timers = new Map();
|
|
@@ -142,6 +143,12 @@ export function restoreOnBoot(onFire) {
|
|
|
142
143
|
function fireOrSkip(workspaceId) {
|
|
143
144
|
if (suspended)
|
|
144
145
|
return;
|
|
146
|
+
const pendingTimer = timers.get(workspaceId);
|
|
147
|
+
if (deferForUpdateMaintenance(`quota-backoff:${workspaceId}`, () => {
|
|
148
|
+
if (timers.get(workspaceId) === pendingTimer)
|
|
149
|
+
fireOrSkip(workspaceId);
|
|
150
|
+
}))
|
|
151
|
+
return;
|
|
145
152
|
timers.delete(workspaceId);
|
|
146
153
|
const pending = getPending(workspaceId);
|
|
147
154
|
if (!pending)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Process-local reservation: restart clears it; only its owner may cancel it. */
|
|
2
|
+
let owner;
|
|
3
|
+
const deferred = new Map();
|
|
4
|
+
const creations = new Set();
|
|
5
|
+
/** Reserve the entire creation/checkout, including asynchronous setup and rollback. */
|
|
6
|
+
export function beginWorkspaceCreation() {
|
|
7
|
+
if (owner !== undefined)
|
|
8
|
+
return null;
|
|
9
|
+
const creation = Symbol('workspace-creation');
|
|
10
|
+
creations.add(creation);
|
|
11
|
+
return () => {
|
|
12
|
+
creations.delete(creation);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function isUpdateMaintenanceActive() {
|
|
16
|
+
return owner !== undefined;
|
|
17
|
+
}
|
|
18
|
+
/** No await between inspecting runtime ownership and closing admission. */
|
|
19
|
+
export function acquireUpdateMaintenance(candidate, isIdle) {
|
|
20
|
+
if (owner !== undefined)
|
|
21
|
+
return owner === candidate ? 'acquired' : 'conflict';
|
|
22
|
+
if (creations.size || !isIdle())
|
|
23
|
+
return 'busy';
|
|
24
|
+
owner = candidate;
|
|
25
|
+
return 'acquired';
|
|
26
|
+
}
|
|
27
|
+
/** Keep scheduler rows/deadlines untouched while delivery is suspended. */
|
|
28
|
+
export function deferForUpdateMaintenance(key, callback) {
|
|
29
|
+
if (owner === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
deferred.set(key, callback);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
export function releaseUpdateMaintenance(candidate) {
|
|
35
|
+
if (owner !== candidate)
|
|
36
|
+
return false;
|
|
37
|
+
owner = undefined;
|
|
38
|
+
for (const callback of deferred.values()) {
|
|
39
|
+
const timer = setTimeout(callback, 0);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
}
|
|
42
|
+
deferred.clear();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
@@ -5,6 +5,7 @@ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
|
5
5
|
import * as orchestrator from './agent/orchestrator.js';
|
|
6
6
|
import * as autoLoopService from './auto-loop-service.js';
|
|
7
7
|
import * as settingsService from './settings-service.js';
|
|
8
|
+
import { deferForUpdateMaintenance, isUpdateMaintenanceActive } from './update-maintenance-service.js';
|
|
8
9
|
import { emitEphemeral } from './websocket-service.js';
|
|
9
10
|
const MIN_DELAY_SECONDS = 60;
|
|
10
11
|
const MAX_DELAY_SECONDS = 21600;
|
|
@@ -22,6 +23,17 @@ export function suspendForShutdown() {
|
|
|
22
23
|
clearTimeout(timer);
|
|
23
24
|
timers.clear();
|
|
24
25
|
}
|
|
26
|
+
/** Claim valid pending wakeups before planned downtime, without changing their requested deadline. */
|
|
27
|
+
export function preserveForUpdateMaintenance() {
|
|
28
|
+
const cutoff = new Date(Date.now() - STALE_WAKEUP_GRACE_MS).toISOString();
|
|
29
|
+
getDb()
|
|
30
|
+
.prepare(`UPDATE pending_wakeups SET retry_at = target_at
|
|
31
|
+
WHERE retry_at IS NULL AND julianday(target_at) >= julianday(?)
|
|
32
|
+
AND workspace_id IN (
|
|
33
|
+
SELECT id FROM workspaces WHERE archived_at IS NULL AND worktree_purged_at IS NULL
|
|
34
|
+
)`)
|
|
35
|
+
.run(cutoff);
|
|
36
|
+
}
|
|
25
37
|
function clamp(n, lo, hi) {
|
|
26
38
|
return Math.max(lo, Math.min(hi, n));
|
|
27
39
|
}
|
|
@@ -60,10 +72,10 @@ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSession
|
|
|
60
72
|
// the original durable wakeup and its timer intact, and fail the MCP receipt.
|
|
61
73
|
const row = getDb()
|
|
62
74
|
.prepare(`INSERT OR REPLACE INTO pending_wakeups
|
|
63
|
-
(workspace_id, target_at, prompt, reason, created_at, agent_session_id)
|
|
64
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
75
|
+
(workspace_id, target_at, prompt, reason, created_at, agent_session_id, retry_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
65
77
|
RETURNING target_at, reason`)
|
|
66
|
-
.get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
|
|
78
|
+
.get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null, isUpdateMaintenanceActive() ? targetAtIso : null);
|
|
67
79
|
const pending = { targetAt: row.target_at, reason: row.reason ?? undefined };
|
|
68
80
|
const existing = timers.get(workspaceId);
|
|
69
81
|
if (existing)
|
|
@@ -168,6 +180,12 @@ export function rehydrate() {
|
|
|
168
180
|
function fire(workspaceId) {
|
|
169
181
|
if (suspended)
|
|
170
182
|
return;
|
|
183
|
+
const pendingTimer = timers.get(workspaceId);
|
|
184
|
+
if (deferForUpdateMaintenance(`wakeup:${workspaceId}`, () => {
|
|
185
|
+
if (timers.get(workspaceId) === pendingTimer)
|
|
186
|
+
fire(workspaceId);
|
|
187
|
+
}))
|
|
188
|
+
return;
|
|
171
189
|
try {
|
|
172
190
|
const db = getDb();
|
|
173
191
|
const row = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
|
|
@@ -3,6 +3,7 @@ import { validateWorkspaceCreationArguments } from '../../shared/workspace-creat
|
|
|
3
3
|
import { CLAUDE_CODE_CAPABILITIES } from './agent/engines/claude-code/capabilities.js';
|
|
4
4
|
import { CODEX_CAPABILITIES } from './agent/engines/codex/capabilities.js';
|
|
5
5
|
import { rollbackCreatedPrCheckout } from './pr-checkout-rollback-service.js';
|
|
6
|
+
import { beginWorkspaceCreation } from './update-maintenance-service.js';
|
|
6
7
|
export class WorkspaceCreationMcpError extends Error {
|
|
7
8
|
status;
|
|
8
9
|
stage;
|
|
@@ -79,6 +80,21 @@ function mapInput(input) {
|
|
|
79
80
|
}
|
|
80
81
|
/** Reuses the application's route validation, lifecycle guards, Git locks and rollback. */
|
|
81
82
|
export async function executeWorkspaceCreationTool(name, raw, dispatch) {
|
|
83
|
+
if (name !== 'create_workspace')
|
|
84
|
+
return executeCreation(name, raw, dispatch);
|
|
85
|
+
// One lease spans both in-process routes and any compensating checkout rollback.
|
|
86
|
+
// Each route also guards direct HTTP callers; nested leases are safe.
|
|
87
|
+
const release = beginWorkspaceCreation();
|
|
88
|
+
if (!release)
|
|
89
|
+
throw new WorkspaceCreationMcpError('Workspace creation is unavailable during update maintenance', 503, 'maintenance');
|
|
90
|
+
try {
|
|
91
|
+
return await executeCreation(name, raw, dispatch);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function executeCreation(name, raw, dispatch) {
|
|
82
98
|
let input;
|
|
83
99
|
try {
|
|
84
100
|
input = validateWorkspaceCreationArguments(name, raw);
|