@edspencer/paddock 0.0.1 → 0.57.0
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/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App factory — builds a fully-wired Fastify instance (REST + WS + optional
|
|
3
|
+
* static SPA) around a ProjectStore + HerdctlService + GitService + GithubAuth.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from index.ts so tests can boot the REAL app in-process against a
|
|
6
|
+
* temp data dir (with a fake `claude` on PATH) without binding a port or
|
|
7
|
+
* registering signal handlers. `index.ts` calls `buildApp()` then `app.listen`.
|
|
8
|
+
*
|
|
9
|
+
* This is a testability seam only — the wiring is identical to the prior inline
|
|
10
|
+
* bootstrap; no behavior changed.
|
|
11
|
+
*/
|
|
12
|
+
import Fastify from "fastify";
|
|
13
|
+
import websocket from "@fastify/websocket";
|
|
14
|
+
import fastifyStatic from "@fastify/static";
|
|
15
|
+
import fastifyMultipart from "@fastify/multipart";
|
|
16
|
+
import fastifySwagger from "@fastify/swagger";
|
|
17
|
+
import fastifySwaggerUi from "@fastify/swagger-ui";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { loadPaddockConfig } from "./config.js";
|
|
22
|
+
import { ProjectStore, ROOT_KEY } from "./projects.js";
|
|
23
|
+
import { AttachmentStore } from "./attachments.js";
|
|
24
|
+
import { HerdctlService } from "./herdctl.js";
|
|
25
|
+
import { GitService } from "./git.js";
|
|
26
|
+
import { GithubAuth } from "./github-auth.js";
|
|
27
|
+
import { makeTranscriber } from "./transcribe.js";
|
|
28
|
+
import { registerRoutes } from "./routes.js";
|
|
29
|
+
import { registerAuth } from "./auth.js";
|
|
30
|
+
import { evaluateBindSafety } from "./bind-safety.js";
|
|
31
|
+
import { renderIndexHtml } from "./brand.js";
|
|
32
|
+
import { makeChatHandler } from "./ws.js";
|
|
33
|
+
import { SweepService } from "./sweep.js";
|
|
34
|
+
import { ArchiveStore } from "./archive.js";
|
|
35
|
+
import { StarStore } from "./star.js";
|
|
36
|
+
import { ReadStateStore } from "./read-state.js";
|
|
37
|
+
import { UnreadStore } from "./unread.js";
|
|
38
|
+
import { ParentDetachStore } from "./parent-detach.js";
|
|
39
|
+
import { QueuedMessageStore } from "./queued-message.js";
|
|
40
|
+
import { RunProvenanceStore } from "./run-provenance.js";
|
|
41
|
+
import { MessageProvenanceStore } from "./message-provenance.js";
|
|
42
|
+
import { ScheduleSessionStore } from "./schedule-session.js";
|
|
43
|
+
import { TriggerSessionStore } from "./trigger-session.js";
|
|
44
|
+
import { PaddockEventBus } from "./event-bus.js";
|
|
45
|
+
import { TriggerService } from "./triggers.js";
|
|
46
|
+
import { buildSwaggerOptions, buildSwaggerUiOptions } from "./openapi.js";
|
|
47
|
+
// Resolve the package version at runtime (dist/app.js → ../package.json) so the
|
|
48
|
+
// generated OpenAPI document's info.version tracks the release without a build step.
|
|
49
|
+
const pkgVersion = (() => {
|
|
50
|
+
try {
|
|
51
|
+
const require = createRequire(import.meta.url);
|
|
52
|
+
return require("../package.json").version ?? "0.0.0";
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "0.0.0";
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
/**
|
|
59
|
+
* True when the (query-stripped) path's last segment carries a file extension —
|
|
60
|
+
* e.g. `/assets/index-ABC.js`, `/favicon.ico`, `/sw.js`. Used by the SPA
|
|
61
|
+
* not-found handler (issue #220) to distinguish a missing static asset (→ 404)
|
|
62
|
+
* from a client-side route (→ index.html shell).
|
|
63
|
+
*/
|
|
64
|
+
function hasFileExtension(pathname) {
|
|
65
|
+
const last = pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
66
|
+
return /\.[^./]+$/.test(last);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Construct and fully register the paddock app. Does NOT listen on a port and
|
|
70
|
+
* installs NO signal handlers — the caller (index.ts in prod, tests in CI)
|
|
71
|
+
* owns the lifecycle. The herdctl fleet is initialized + started; a failure
|
|
72
|
+
* there is logged and swallowed (project CRUD still works), matching prod.
|
|
73
|
+
*/
|
|
74
|
+
export async function buildApp(opts = {}) {
|
|
75
|
+
const cfg = opts.config ?? loadPaddockConfig();
|
|
76
|
+
const app = Fastify({
|
|
77
|
+
logger: { level: cfg.logLevel },
|
|
78
|
+
});
|
|
79
|
+
// --- safe-by-default bind guard (#435) --------------------------------
|
|
80
|
+
// Fail closed if the resolved bind host is non-loopback AND auth is `none`:
|
|
81
|
+
// that would expose an unauthenticated Paddock (which runs code + spends
|
|
82
|
+
// tokens) on a routable interface. Mirrors the jwt-without-JWKS fail-closed
|
|
83
|
+
// check below. A dangerously-worded env opt-in downgrades the refusal to a
|
|
84
|
+
// loud one-line boot warning.
|
|
85
|
+
{
|
|
86
|
+
const decision = evaluateBindSafety({
|
|
87
|
+
host: cfg.host,
|
|
88
|
+
authMode: cfg.auth.mode,
|
|
89
|
+
dangerouslyAllowOpen: cfg.dangerouslyAllowOpen,
|
|
90
|
+
});
|
|
91
|
+
if (decision.action === "refuse")
|
|
92
|
+
throw new Error(decision.message);
|
|
93
|
+
if (decision.action === "warn")
|
|
94
|
+
app.log.warn(decision.message);
|
|
95
|
+
}
|
|
96
|
+
// --- auth (provider-agnostic) -----------------------------------------
|
|
97
|
+
// Registered first so its onRequest hook guards every REST + WS request
|
|
98
|
+
// (health probes are exempted inside). Default mode `none` is a no-op. Throws
|
|
99
|
+
// on a fatal misconfig (e.g. jwt mode without a JWKS URL) — fail closed.
|
|
100
|
+
registerAuth(app, cfg.auth);
|
|
101
|
+
// --- project layer + herdctl ------------------------------------------
|
|
102
|
+
const projects = new ProjectStore(cfg.projectsRoot);
|
|
103
|
+
await projects.init();
|
|
104
|
+
const herdctl = new HerdctlService(cfg);
|
|
105
|
+
const git = new GitService(cfg.projectsRoot, cfg.gitAuthor);
|
|
106
|
+
const githubAuth = new GithubAuth(path.join(cfg.dataDir, "github-auth.json"), cfg.githubClientId);
|
|
107
|
+
const archive = new ArchiveStore(cfg.dataDir);
|
|
108
|
+
// Per-chat starred/pinned-flag sidecar (#373). Orthogonal to `archive`; the
|
|
109
|
+
// client floats starred chats to the top of both the active and Archived lists.
|
|
110
|
+
const star = new StarStore(cfg.dataDir);
|
|
111
|
+
// Per-user (or shared, in `none` mode) chat read-state sidecar (#189).
|
|
112
|
+
const readState = new ReadStateStore(cfg.dataDir);
|
|
113
|
+
// Per-user manual "unread" override sidecar (#458) — layered on read-state so a
|
|
114
|
+
// chat can be re-flagged unread after its last turn was seen ("look at it again
|
|
115
|
+
// in the morning"). Cleared whenever the chat is marked seen.
|
|
116
|
+
const unread = new UnreadStore(cfg.dataDir);
|
|
117
|
+
// Explicit "this chat was detached from its parent" sidecar (#508). Checked
|
|
118
|
+
// ahead of both parent-resolution tiers, so a detach survives the inference
|
|
119
|
+
// that would otherwise re-derive the old parent on the next list load.
|
|
120
|
+
const parentDetach = new ParentDetachStore(cfg.dataDir);
|
|
121
|
+
// Per-chat queued message sidecar (#197) for server-side auto-send.
|
|
122
|
+
const queuedMessage = new QueuedMessageStore(cfg.dataDir);
|
|
123
|
+
// Per-chat provenance sidecar (issue #261): records how each chat was created
|
|
124
|
+
// (origin human/scheduled/spawned + spawn depth) so #262 can depth-gate
|
|
125
|
+
// spawning and #267 can badge provenance. A1 only carries/persists the marker.
|
|
126
|
+
const runProvenance = new RunProvenanceStore(cfg.dataDir);
|
|
127
|
+
// Per-MESSAGE provenance sidecar (issue #290): records WHO injected each
|
|
128
|
+
// machine-added turn (send_message / schedule / spawn kickoff) so the chat
|
|
129
|
+
// history can attribute it. The per-message analog of runProvenance.
|
|
130
|
+
const messageProvenance = new MessageProvenanceStore(cfg.dataDir);
|
|
131
|
+
// Owned-session sidecar for accreting schedules (issue #265 / DD-2): maps a
|
|
132
|
+
// `resume_session: true` schedule to the one chat it accretes into across fires.
|
|
133
|
+
const scheduleSessions = new ScheduleSessionStore(cfg.dataDir);
|
|
134
|
+
// In-process lifecycle event bus (Epic T). The archive commit sites (REST route +
|
|
135
|
+
// self-MCP archive tool) `emit` onto the bus; the chat handler subscribes and fires
|
|
136
|
+
// the project's enabled event triggers via startAgentTurn.
|
|
137
|
+
const events = new PaddockEventBus();
|
|
138
|
+
// Unified trigger registry + owned-session sidecar (Epic T / T1). TriggerService is
|
|
139
|
+
// the single CRUD surface over both fire paths (event bus + schedule handler); the
|
|
140
|
+
// sidecar rebinds a `run.session: "resume"` trigger's owned chat after a restart.
|
|
141
|
+
const triggers = new TriggerService(projects, herdctl);
|
|
142
|
+
const triggerSessions = new TriggerSessionStore(cfg.dataDir);
|
|
143
|
+
// Store for files shared via mcp__paddock__send_file (issue #112). Copies live
|
|
144
|
+
// outside any project working dir so they never show up as untracked repo files.
|
|
145
|
+
const attachments = new AttachmentStore(path.join(cfg.dataDir, "attachments"));
|
|
146
|
+
await attachments.init();
|
|
147
|
+
const transcriber = makeTranscriber(cfg.transcription);
|
|
148
|
+
app.log.info({ mode: cfg.transcription.mode, available: transcriber.available }, "voice dictation capability");
|
|
149
|
+
// `list()` enumerates the root's CHILDREN (it walks subdirectories), so the
|
|
150
|
+
// root workspace itself is never in it. It always exists — the instance's own
|
|
151
|
+
// directory — so resolve it explicitly and register its keeper alongside every
|
|
152
|
+
// child's. The root keeper is an ordinary keeper in every respect.
|
|
153
|
+
const rootWorkspace = await projects.get(ROOT_KEY);
|
|
154
|
+
const initialProjects = [...(await projects.list()), rootWorkspace];
|
|
155
|
+
try {
|
|
156
|
+
await herdctl.init(initialProjects);
|
|
157
|
+
await herdctl.start();
|
|
158
|
+
app.log.info("herdctl FleetManager initialized");
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
app.log.error({ err }, "FleetManager init/start failed — chat will be unavailable");
|
|
162
|
+
}
|
|
163
|
+
// --- post-turn curation sweep (overview + changelog) -------------------
|
|
164
|
+
const sweep = new SweepService({
|
|
165
|
+
herdctl,
|
|
166
|
+
projects,
|
|
167
|
+
dataDir: cfg.dataDir,
|
|
168
|
+
minIntervalMs: cfg.sweepMinIntervalMs,
|
|
169
|
+
budget: cfg.curation,
|
|
170
|
+
logger: app.log,
|
|
171
|
+
});
|
|
172
|
+
// --- transport ---------------------------------------------------------
|
|
173
|
+
await app.register(websocket);
|
|
174
|
+
// Multipart parses the mic-recording upload on POST /api/transcribe. The size
|
|
175
|
+
// cap mirrors the transcription config so an oversized blob is rejected before
|
|
176
|
+
// it's buffered in full.
|
|
177
|
+
await app.register(fastifyMultipart, {
|
|
178
|
+
limits: { fileSize: cfg.transcription.maxUploadBytes, files: 1 },
|
|
179
|
+
});
|
|
180
|
+
// --- OpenAPI (derived from route schemas) -----------------------------
|
|
181
|
+
// @fastify/swagger MUST register before the routes: it hooks `onRoute` to
|
|
182
|
+
// collect each route's schema into a live OpenAPI document. Swagger UI mounts
|
|
183
|
+
// at /docs and reads that document (raw spec at /docs/json). Both sit behind
|
|
184
|
+
// whatever auth mode is configured (no special exemption) — the docs are
|
|
185
|
+
// gated exactly like the API they describe. The security schemes advertised in
|
|
186
|
+
// the spec reflect this instance's auth mode (see openapi.ts / authDoc).
|
|
187
|
+
//
|
|
188
|
+
// The topbar logo + favicon are the shipped Paddock icons from the built web
|
|
189
|
+
// bundle (icons/*.png), so the docs are branded like the app; missing assets
|
|
190
|
+
// (API-only mode with no web dist) just fall back to the stock look.
|
|
191
|
+
if (cfg.openapi.enabled) {
|
|
192
|
+
const readImage = async (rel, type) => {
|
|
193
|
+
try {
|
|
194
|
+
return { type, content: await fs.readFile(path.join(cfg.webDist, rel)) };
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const [swaggerLogo, swaggerFavicon] = await Promise.all([
|
|
201
|
+
readImage(path.join("icons", "icon-192.png"), "image/png"),
|
|
202
|
+
readImage(path.join("icons", "favicon-32.png"), "image/png"),
|
|
203
|
+
]);
|
|
204
|
+
await app.register(fastifySwagger, buildSwaggerOptions(pkgVersion, cfg.auth));
|
|
205
|
+
await app.register(fastifySwaggerUi, buildSwaggerUiOptions({
|
|
206
|
+
routePrefix: cfg.openapi.path,
|
|
207
|
+
logo: swaggerLogo,
|
|
208
|
+
favicon: swaggerFavicon,
|
|
209
|
+
accent: cfg.brand.accent,
|
|
210
|
+
}));
|
|
211
|
+
// Stable, tool-friendly alias for the raw spec (e.g. `/open-api.json`), next
|
|
212
|
+
// to the UI's own `<path>/json`. Hidden from the spec itself.
|
|
213
|
+
app.get(`${cfg.openapi.path}.json`, { schema: { hide: true } }, () => app.swagger());
|
|
214
|
+
app.log.info({ path: cfg.openapi.path }, "OpenAPI reference mounted");
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
app.log.info("OpenAPI reference off (set PADDOCK_OPENAPI_ENABLED=1 to mount /open-api)");
|
|
218
|
+
}
|
|
219
|
+
const chatHandler = makeChatHandler({ herdctl, projects, sweep, attachments, queuedMessage, runProvenance, messageProvenance, archive, scheduleSessions, events, triggers, triggerSessions, cfg });
|
|
220
|
+
// --- external Management API (#312 M1) ---------------------------------
|
|
221
|
+
// Surface how the `managementApi` block resolved. A malformed client is an
|
|
222
|
+
// ERROR (the operator wrote something meaningless); a dropped one is a WARNING
|
|
223
|
+
// (its credential wouldn't resolve, so it failed closed). Logged here because
|
|
224
|
+
// config loading has no logger of its own.
|
|
225
|
+
for (const message of cfg.managementApiDiagnostics.errors)
|
|
226
|
+
app.log.error(message);
|
|
227
|
+
for (const message of cfg.managementApiDiagnostics.warnings)
|
|
228
|
+
app.log.warn(message);
|
|
229
|
+
if (cfg.managementApi.clients.length > 0) {
|
|
230
|
+
app.log.info({
|
|
231
|
+
clients: cfg.managementApi.clients.map((c) => c.clientId),
|
|
232
|
+
instanceId: cfg.managementApi.instanceId,
|
|
233
|
+
}, "management API: /mcp enabled (self-authenticated — independent of PADDOCK_AUTH_MODE and of any proxy)");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
app.log.info("management API: /mcp disabled (no managementApi.clients configured) — the endpoint 404s");
|
|
237
|
+
}
|
|
238
|
+
await registerRoutes(app, { projects, herdctl, git, githubAuth, transcriber, archive, star, readState, unread, parentDetach, runProvenance, messageProvenance, attachments, fireTrigger: chatHandler.fireTrigger, managementOpsContext: chatHandler.managementOpsContext, events, triggers, cfg });
|
|
239
|
+
await app.register(async (scoped) => {
|
|
240
|
+
// `hide: true` keeps the WS upgrade out of the OpenAPI doc — it's not a REST
|
|
241
|
+
// endpoint (a Swagger "Try it out" against it would just fail); the WS frame
|
|
242
|
+
// protocol is documented in the spec description + docs/API.md instead.
|
|
243
|
+
scoped.get("/ws", { websocket: true, schema: { hide: true } }, (socket) => {
|
|
244
|
+
void chatHandler.handle(socket);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
// --- static SPA (production) ------------------------------------------
|
|
248
|
+
const serveStatic = opts.serveStatic ?? true;
|
|
249
|
+
if (serveStatic) {
|
|
250
|
+
const hasWebDist = await fs
|
|
251
|
+
.stat(cfg.webDist)
|
|
252
|
+
.then((s) => s.isDirectory())
|
|
253
|
+
.catch(() => false);
|
|
254
|
+
if (hasWebDist) {
|
|
255
|
+
// Inject per-instance branding (issue #34) into index.html ONCE at
|
|
256
|
+
// startup, then serve that string for the app root + every client-side
|
|
257
|
+
// route.
|
|
258
|
+
const rawIndex = await fs.readFile(path.join(cfg.webDist, "index.html"), "utf8");
|
|
259
|
+
const indexHtml = renderIndexHtml(rawIndex, cfg.brand, {
|
|
260
|
+
enabled: cfg.openapi.enabled,
|
|
261
|
+
path: cfg.openapi.path,
|
|
262
|
+
});
|
|
263
|
+
const sendIndex = (reply) => reply.type("text/html; charset=utf-8").send(indexHtml);
|
|
264
|
+
// Short-circuit the app root before fastifyStatic can serve the RAW
|
|
265
|
+
// index.html (a per-file route with `wildcard: false`). An onRequest hook
|
|
266
|
+
// that replies stops routing, so we own the branded document without
|
|
267
|
+
// registering a route that would collide with the static plugin.
|
|
268
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
269
|
+
if (req.method !== "GET")
|
|
270
|
+
return;
|
|
271
|
+
const p = req.url.split("?")[0];
|
|
272
|
+
if (p === "/" || p === "/index.html")
|
|
273
|
+
return sendIndex(reply);
|
|
274
|
+
});
|
|
275
|
+
await app.register(fastifyStatic, { root: cfg.webDist, wildcard: false });
|
|
276
|
+
app.setNotFoundHandler((req, reply) => {
|
|
277
|
+
if (req.method === "GET" &&
|
|
278
|
+
!req.url.startsWith("/api") &&
|
|
279
|
+
!req.url.startsWith("/ws") &&
|
|
280
|
+
// #312: `/.well-known/` and `/mcp` are MACHINE surfaces and must 404
|
|
281
|
+
// honestly when absent. Both are extension-less, so without this they
|
|
282
|
+
// would fall into the client-side-route branch below and be answered
|
|
283
|
+
// with the SPA shell + 200 — which (a) holes the fail-closed guarantee
|
|
284
|
+
// (an unconfigured management API would "exist" at the discovery URL)
|
|
285
|
+
// and (b) breaks MCP OAuth discovery: a client fetching the
|
|
286
|
+
// protected-resource metadata receives HTML, fails to parse it, and
|
|
287
|
+
// silently falls back to treating Paddock as its own authorization
|
|
288
|
+
// server, with no error naming the real cause.
|
|
289
|
+
!req.url.startsWith("/.well-known/") &&
|
|
290
|
+
!req.url.startsWith("/mcp")) {
|
|
291
|
+
// Serve the SPA shell for client-side routes — but NEVER for a request
|
|
292
|
+
// that is clearly a missing *static asset* (a stale hashed chunk after a
|
|
293
|
+
// deploy, or any missing file). Those must 404 (issue #220): otherwise the
|
|
294
|
+
// browser receives index.html (text/html) for a JS/CSS module and throws
|
|
295
|
+
// "Failed to load module script…" ("Unexpected application error: a module
|
|
296
|
+
// script failed"), and the service worker would cache that HTML under the
|
|
297
|
+
// asset URL and serve it indefinitely.
|
|
298
|
+
//
|
|
299
|
+
// A request is treated as a client-side route (→ shell) when it is a real
|
|
300
|
+
// browser navigation (Accept: text/html, or Sec-Fetch-Mode: navigate) OR
|
|
301
|
+
// its path has no file extension. Everything else with a file extension
|
|
302
|
+
// (e.g. /assets/index-DEADBEEF.js, /favicon.ico) 404s. Dotted client routes
|
|
303
|
+
// such as /projects/x/files/README.md still resolve to the shell because a
|
|
304
|
+
// reload/deep-link of them is a navigation carrying Accept: text/html.
|
|
305
|
+
const p = req.url.split("?")[0];
|
|
306
|
+
const accept = String(req.headers["accept"] ?? "");
|
|
307
|
+
const isNavigation = req.headers["sec-fetch-mode"] === "navigate" || accept.includes("text/html");
|
|
308
|
+
if (isNavigation || !hasFileExtension(p))
|
|
309
|
+
return sendIndex(reply);
|
|
310
|
+
}
|
|
311
|
+
return reply.code(404).send({ error: "not found" });
|
|
312
|
+
});
|
|
313
|
+
app.log.info({ webDist: cfg.webDist, brand: cfg.brand.name }, "serving built web SPA");
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
app.log.warn({ webDist: cfg.webDist }, "web dist not found — API-only mode");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const close = async () => {
|
|
320
|
+
sweep.stop();
|
|
321
|
+
await herdctl.stop().catch(() => undefined);
|
|
322
|
+
await app.close().catch(() => undefined);
|
|
323
|
+
};
|
|
324
|
+
return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, close };
|
|
325
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare class ArchiveStore {
|
|
2
|
+
private readonly stateFile;
|
|
3
|
+
/** In-memory set of archived keys (loaded once, written through on change). */
|
|
4
|
+
private archived;
|
|
5
|
+
/**
|
|
6
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
7
|
+
* Caching only the resolved `archived` set would let several toggles that
|
|
8
|
+
* begin before the first `fs.readFile` resolves each build their own set and
|
|
9
|
+
* race on the assignment — a lost update (last writer wins, dropping the
|
|
10
|
+
* others' keys).
|
|
11
|
+
*/
|
|
12
|
+
private loadPromise;
|
|
13
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
14
|
+
private writing;
|
|
15
|
+
constructor(dataDir: string);
|
|
16
|
+
/** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
17
|
+
private ensureLoaded;
|
|
18
|
+
/** Whether a chat is archived. Non-throwing: any load error reads as false. */
|
|
19
|
+
isArchived(agent: string, sessionId: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Set (or clear) a chat's archived flag, persisting the change. Idempotent.
|
|
22
|
+
* Returns whether the flag actually CHANGED — so a caller can fire an "archived"
|
|
23
|
+
* lifecycle event (Epic G / G1) exactly once per real transition, not on a
|
|
24
|
+
* redundant re-archive of an already-archived chat.
|
|
25
|
+
*/
|
|
26
|
+
setArchived(agent: string, sessionId: string, archived: boolean): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Set (or clear) the archived flag for MANY chats in ONE write (#508).
|
|
29
|
+
*
|
|
30
|
+
* The subtree actions apply an archive to a parent and every descendant. Looped
|
|
31
|
+
* one-at-a-time that's N round trips, and a failure partway leaves a torn family
|
|
32
|
+
* (parent archived, half the children not) that the user has to unpick by hand.
|
|
33
|
+
* Here the whole set lands in the in-memory set first and persists once, so the
|
|
34
|
+
* sidecar only ever holds the before or the after state.
|
|
35
|
+
*
|
|
36
|
+
* Returns the ids whose flag actually CHANGED, so the caller can fire one
|
|
37
|
+
* `onArchive` per real transition — the same contract as {@link setArchived}.
|
|
38
|
+
*/
|
|
39
|
+
setManyArchived(agent: string, sessionIds: readonly string[], archived: boolean): Promise<string[]>;
|
|
40
|
+
/** Write-through, serialised so overlapping toggles can't corrupt the file. */
|
|
41
|
+
private persist;
|
|
42
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ArchiveStore — a Paddock-side sidecar for the per-chat "archived" flag (#95).
|
|
3
|
+
*
|
|
4
|
+
* Archiving files a finished chat away into a collapsible "Archived" section
|
|
5
|
+
* without touching its transcript, so it stays fully openable/resumable/forkable
|
|
6
|
+
* — the flag is purely presentational metadata.
|
|
7
|
+
*
|
|
8
|
+
* The natural home for this is @herdctl/core's SessionMetadataStore (the same
|
|
9
|
+
* layer that persists custom chat names), whose schema already reserves room for
|
|
10
|
+
* a future `archived` field. Until that ships upstream, Paddock keeps its own
|
|
11
|
+
* tiny JSON sidecar in the data dir — the exact same pattern SweepService uses
|
|
12
|
+
* for its watermark state. Keyed by `<agent>\0<sessionId>` (NUL-separated) so
|
|
13
|
+
* chats under different agents can never collide — a NUL can occur in neither an
|
|
14
|
+
* agent name (`keeper-<slug>`) nor a UUID. Only archived entries
|
|
15
|
+
* are stored (an unarchive deletes the key), keeping the file compact.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
const STATE_FILE = "archive-state.json";
|
|
20
|
+
/** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
|
|
21
|
+
const KEY_SEP = "\u0000";
|
|
22
|
+
/** Compose the storage key for an (agent, session) pair. */
|
|
23
|
+
function keyOf(agent, sessionId) {
|
|
24
|
+
return `${agent}${KEY_SEP}${sessionId}`;
|
|
25
|
+
}
|
|
26
|
+
export class ArchiveStore {
|
|
27
|
+
stateFile;
|
|
28
|
+
/** In-memory set of archived keys (loaded once, written through on change). */
|
|
29
|
+
archived = null;
|
|
30
|
+
/**
|
|
31
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
32
|
+
* Caching only the resolved `archived` set would let several toggles that
|
|
33
|
+
* begin before the first `fs.readFile` resolves each build their own set and
|
|
34
|
+
* race on the assignment — a lost update (last writer wins, dropping the
|
|
35
|
+
* others' keys).
|
|
36
|
+
*/
|
|
37
|
+
loadPromise = null;
|
|
38
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
39
|
+
writing = Promise.resolve();
|
|
40
|
+
constructor(dataDir) {
|
|
41
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
42
|
+
}
|
|
43
|
+
/** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
44
|
+
ensureLoaded() {
|
|
45
|
+
if (this.archived)
|
|
46
|
+
return Promise.resolve(this.archived);
|
|
47
|
+
if (!this.loadPromise) {
|
|
48
|
+
this.loadPromise = (async () => {
|
|
49
|
+
let keys = [];
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (Array.isArray(parsed))
|
|
54
|
+
keys = parsed.filter((k) => typeof k === "string");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* missing or unreadable — start empty */
|
|
58
|
+
}
|
|
59
|
+
this.archived = new Set(keys);
|
|
60
|
+
return this.archived;
|
|
61
|
+
})();
|
|
62
|
+
}
|
|
63
|
+
return this.loadPromise;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a chat is archived. Non-throwing: any load error reads as false. */
|
|
66
|
+
async isArchived(agent, sessionId) {
|
|
67
|
+
const set = await this.ensureLoaded().catch(() => new Set());
|
|
68
|
+
return set.has(keyOf(agent, sessionId));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Set (or clear) a chat's archived flag, persisting the change. Idempotent.
|
|
72
|
+
* Returns whether the flag actually CHANGED — so a caller can fire an "archived"
|
|
73
|
+
* lifecycle event (Epic G / G1) exactly once per real transition, not on a
|
|
74
|
+
* redundant re-archive of an already-archived chat.
|
|
75
|
+
*/
|
|
76
|
+
async setArchived(agent, sessionId, archived) {
|
|
77
|
+
const set = await this.ensureLoaded();
|
|
78
|
+
const key = keyOf(agent, sessionId);
|
|
79
|
+
if (archived === set.has(key))
|
|
80
|
+
return false; // no-op — avoid a needless write
|
|
81
|
+
if (archived)
|
|
82
|
+
set.add(key);
|
|
83
|
+
else
|
|
84
|
+
set.delete(key);
|
|
85
|
+
await this.persist(set);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Set (or clear) the archived flag for MANY chats in ONE write (#508).
|
|
90
|
+
*
|
|
91
|
+
* The subtree actions apply an archive to a parent and every descendant. Looped
|
|
92
|
+
* one-at-a-time that's N round trips, and a failure partway leaves a torn family
|
|
93
|
+
* (parent archived, half the children not) that the user has to unpick by hand.
|
|
94
|
+
* Here the whole set lands in the in-memory set first and persists once, so the
|
|
95
|
+
* sidecar only ever holds the before or the after state.
|
|
96
|
+
*
|
|
97
|
+
* Returns the ids whose flag actually CHANGED, so the caller can fire one
|
|
98
|
+
* `onArchive` per real transition — the same contract as {@link setArchived}.
|
|
99
|
+
*/
|
|
100
|
+
async setManyArchived(agent, sessionIds, archived) {
|
|
101
|
+
const set = await this.ensureLoaded();
|
|
102
|
+
const changed = [];
|
|
103
|
+
for (const sessionId of sessionIds) {
|
|
104
|
+
const key = keyOf(agent, sessionId);
|
|
105
|
+
if (archived === set.has(key))
|
|
106
|
+
continue; // already in the target state
|
|
107
|
+
if (archived)
|
|
108
|
+
set.add(key);
|
|
109
|
+
else
|
|
110
|
+
set.delete(key);
|
|
111
|
+
changed.push(sessionId);
|
|
112
|
+
}
|
|
113
|
+
if (changed.length)
|
|
114
|
+
await this.persist(set);
|
|
115
|
+
return changed;
|
|
116
|
+
}
|
|
117
|
+
/** Write-through, serialised so overlapping toggles can't corrupt the file. */
|
|
118
|
+
persist(set) {
|
|
119
|
+
this.writing = this.writing.then(async () => {
|
|
120
|
+
const json = JSON.stringify([...set], null, 2);
|
|
121
|
+
await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
|
|
122
|
+
});
|
|
123
|
+
return this.writing;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound attachment config — the config foundation for issue #328 Phase 1
|
|
3
|
+
* (ChatGPT-style file/image upload in the composer, "Approach A").
|
|
4
|
+
*
|
|
5
|
+
* ── What this governs ────────────────────────────────────────────────────────
|
|
6
|
+
* A user can pick / drag / paste files into the composer; each is copied into the
|
|
7
|
+
* per-instance attachment store (reusing the `send_file` store, #112) and the
|
|
8
|
+
* keeper is pointed at the absolute paths so its `Read` tool can view them
|
|
9
|
+
* (native vision on images/PDFs). This module resolves the four knobs that gate
|
|
10
|
+
* that flow: whether it's enabled at all, a per-file size cap, a per-message file
|
|
11
|
+
* count cap, and an allow-list of file types.
|
|
12
|
+
*
|
|
13
|
+
* ── Config discipline (the `recovery`/`driveMode` pattern) ───────────────────
|
|
14
|
+
* Every field is an instance default (`PADDOCK_ATTACHMENTS_*` env, YAML instance
|
|
15
|
+
* file beneath it) with an optional PER-PROJECT override (`project.yaml` →
|
|
16
|
+
* {@link import("./projects.js").ProjectYaml.attachments}). An absent/invalid
|
|
17
|
+
* override field inherits the instance default, resolved at request time by
|
|
18
|
+
* {@link resolveAttachmentsConfig} — never baked into the DTO (mirrors how
|
|
19
|
+
* `recovery` resolves via {@link import("./recovery-config.js").resolveRecoveryConfig}).
|
|
20
|
+
* A malformed override is ignored rather than fatal so a hand-edited `project.yaml`
|
|
21
|
+
* can't wedge an upload.
|
|
22
|
+
*
|
|
23
|
+
* ── allowedTypes semantics ───────────────────────────────────────────────────
|
|
24
|
+
* Each entry is either a MIME pattern (contains `/`, `*` wildcards a segment —
|
|
25
|
+
* `image/*`, `*/*`) or an extension (starts with `.` — `.csv`, `.ipynb`). A file
|
|
26
|
+
* is allowed if its MIME matches any MIME pattern OR its extension matches any
|
|
27
|
+
* extension entry. The sentinel `"*"` (or `"*/*"`) allows everything (the
|
|
28
|
+
* default). The extension fallback matters because browsers report empty/generic
|
|
29
|
+
* MIME for many real attachments (`.md`, `.ts`, `.heic` → `""`/`text/plain`).
|
|
30
|
+
*
|
|
31
|
+
* NOTE: `File.type` / multipart content-type are client-provided and spoofable,
|
|
32
|
+
* so allowedTypes is a hygiene/UX guardrail, NOT a security boundary (no
|
|
33
|
+
* magic-byte sniffing in v1) — appropriate for a personal/LAN tool.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Resolved attachment config — all fields concrete. Held on {@link
|
|
37
|
+
* import("./config.js").PaddockConfig.attachments} (instance defaults) and
|
|
38
|
+
* produced per-request by {@link resolveAttachmentsConfig} (project override else
|
|
39
|
+
* instance).
|
|
40
|
+
*/
|
|
41
|
+
export interface AttachmentsConfig {
|
|
42
|
+
/**
|
|
43
|
+
* Master switch for inbound composer uploads. Default ON. When off, the upload
|
|
44
|
+
* endpoint 403s and the composer hides its picker/drop/paste affordances. Env
|
|
45
|
+
* `PADDOCK_ATTACHMENTS_ENABLED`.
|
|
46
|
+
*/
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Per-file size cap in megabytes (1 MB = 1024*1024 bytes). A larger file is
|
|
50
|
+
* rejected before it's written. Default 25. Env
|
|
51
|
+
* `PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB`.
|
|
52
|
+
*/
|
|
53
|
+
maxFileSizeMb: number;
|
|
54
|
+
/**
|
|
55
|
+
* How many files a single message may carry. Enforced client-side (tray cap)
|
|
56
|
+
* and server-side (per upload request + at send). Default 10. Env
|
|
57
|
+
* `PADDOCK_ATTACHMENTS_MAX_FILES_PER_MESSAGE`.
|
|
58
|
+
*/
|
|
59
|
+
maxFilesPerMessage: number;
|
|
60
|
+
/**
|
|
61
|
+
* Allow-list of MIME patterns / extensions (see the module doc). Default
|
|
62
|
+
* `["*"]` (allow everything). Env `PADDOCK_ATTACHMENTS_ALLOWED_TYPES`
|
|
63
|
+
* (comma-separated); YAML/project take a real array.
|
|
64
|
+
*/
|
|
65
|
+
allowedTypes: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A per-project attachment override as stored in `project.yaml` — every field
|
|
69
|
+
* optional (an absent field inherits the instance default at request time).
|
|
70
|
+
*/
|
|
71
|
+
export type AttachmentsOverride = Partial<AttachmentsConfig>;
|
|
72
|
+
/**
|
|
73
|
+
* The built-in attachment defaults (beneath env + YAML + per-project override):
|
|
74
|
+
* enabled, 25 MB/file, 10 files/message, allow-all.
|
|
75
|
+
*/
|
|
76
|
+
export declare const DEFAULT_ATTACHMENTS: AttachmentsConfig;
|
|
77
|
+
/** 1 MB in bytes — the unit `maxFileSizeMb` is expressed in. */
|
|
78
|
+
export declare const BYTES_PER_MB: number;
|
|
79
|
+
/** The per-file size cap in BYTES for a resolved config. */
|
|
80
|
+
export declare function maxFileBytes(cfg: AttachmentsConfig): number;
|
|
81
|
+
/**
|
|
82
|
+
* Normalise an allowedTypes list: keep only non-blank strings, trimmed and
|
|
83
|
+
* lower-cased (MIME + extensions are case-insensitive). Returns `undefined` when
|
|
84
|
+
* nothing valid remains (so an empty list never silently means "deny all").
|
|
85
|
+
*/
|
|
86
|
+
export declare function sanitizeAllowedTypes(value: unknown): string[] | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
|
|
89
|
+
* into an {@link AttachmentsOverride}, dropping any field that is missing or
|
|
90
|
+
* invalid, and returning `undefined` when nothing valid remains (so an empty
|
|
91
|
+
* override is never persisted). `enabled` must be a real boolean; the numeric
|
|
92
|
+
* knobs must be positive integers; `allowedTypes` must be a non-empty string
|
|
93
|
+
* array. Defensive by design — a malformed hand-edit degrades to "inherit the
|
|
94
|
+
* instance default", never a crash.
|
|
95
|
+
*/
|
|
96
|
+
export declare function sanitizeAttachmentsOverride(value: unknown): AttachmentsOverride | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the effective attachment config for a request: a valid per-project
|
|
99
|
+
* override wins field-by-field; every absent field inherits the instance default.
|
|
100
|
+
* Mirrors how `recovery`/`driveMode` resolve — the override is carried on disk
|
|
101
|
+
* only for the fields explicitly set, so an absent value transparently inherits
|
|
102
|
+
* the instance (env/YAML) default. The override is re-sanitised so a corrupt
|
|
103
|
+
* on-disk value can't leak through.
|
|
104
|
+
*/
|
|
105
|
+
export declare function resolveAttachmentsConfig(override: AttachmentsOverride | undefined, instanceDefault: AttachmentsConfig): AttachmentsConfig;
|
|
106
|
+
/**
|
|
107
|
+
* True when a file (identified by its browser-reported `mime` and its
|
|
108
|
+
* `filename`) is allowed by `allowedTypes`. Allowed if any entry matches: a
|
|
109
|
+
* MIME-pattern entry (contains `/`) is tested against `mime`; an extension entry
|
|
110
|
+
* (starts with `.`) against the filename's extension. The sentinel `"*"` /
|
|
111
|
+
* `"*/*"` allows everything. See the module doc for why the extension fallback
|
|
112
|
+
* exists. `mime`/`filename` may be empty (browsers often omit one).
|
|
113
|
+
*/
|
|
114
|
+
export declare function isTypeAllowed(allowedTypes: string[], mime: string | undefined, filename: string | undefined): boolean;
|