@edspencer/paddock 0.66.1 → 0.67.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/package.json +1 -1
- package/packages/server/dist/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +32 -2
- package/packages/server/dist/queued-message.js +87 -8
- package/packages/server/dist/routes/chats.js +41 -10
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +10 -0
- package/packages/server/dist/session-hub.js +16 -2
- package/packages/server/dist/ws-protocol.d.ts +67 -0
- package/packages/server/dist/ws-protocol.js +28 -13
- package/packages/server/dist/ws.js +90 -24
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-MaSe5aAI.js} +11 -11
- package/packages/web/dist/assets/ProjectMenu-DmWpr9od.js +2 -0
- package/packages/web/dist/assets/ProjectRedirect-BJeDERUd.js +2 -0
- package/packages/web/dist/assets/ProjectView-Bbecs1u7.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BE4cAtu5.js +2 -0
- package/packages/web/dist/assets/Surfaces-DtSsbnLh.js +2 -0
- package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-NLb6J_GZ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-BbiSIctn.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-Br1sRzRE.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-DUvJurko.js} +1 -1
- package/packages/web/dist/assets/channel-bezC0w5b.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-Cm_E_LAD.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-CK6R2MCN.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-D7HXvxGy.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-BLwevzlY.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-CpLWD5so.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-BNlt1t19.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CIt5gAq8.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B1uwIQ9c.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +2 -0
- package/packages/web/dist/assets/{core-DwSEw_oE.js → core-HXUN76Po.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-DJ4_5jxB.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Bc9-haAu.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-nLOtPDzu.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-DThV84R3.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-C5hcR1vA.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-BEFzrSHy.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-BY5lPsX7.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-y_kB8ETg.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-Cuhha5cF.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-DFJpciK5.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-FoA0yuh-.js} +1 -1
- package/packages/web/dist/assets/index-BxLurJvz.css +1 -0
- package/packages/web/dist/assets/index-C_PXsGTD.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-CKYqD8-c.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-CCIxHIBw.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-lGYoSyk0.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-B3D7CIwy.js} +1 -1
- package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-BiplJBex.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-BJ7aEinZ.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-BiXcOtHo.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-tnkj45YF.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB-LqReq3Gl.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-C1uk9Jty.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-eQQ3OKrV.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-C6-a0lH4.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CsTOkKqz.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-DUBXH1x-.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-CcQrNnou.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Br823uUV.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-DRBECQc_.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6--HtSFP24.js} +1 -1
- package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
- package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
- package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
- package/packages/web/dist/index.html +61 -12
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
- package/packages/web/dist/assets/ProjectRedirect-Dd3h5V8h.js +0 -2
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
- package/packages/web/dist/assets/ProjectsGrid-BaUE8zaF.js +0 -2
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
- package/packages/web/dist/assets/format-u-YtEZFd.js +0 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
- package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +0 -2
|
@@ -71,9 +71,54 @@ export declare function repoCheckoutName(repo: string): string;
|
|
|
71
71
|
* (it does NOT follow symlinks — callers that care resolve them beforehand).
|
|
72
72
|
*
|
|
73
73
|
* The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
|
|
74
|
-
* `/data/projects-old` a child of `/data/projects`.
|
|
74
|
+
* `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
|
|
75
|
+
* parent whose string already ends in a separator — the filesystem root — where
|
|
76
|
+
* appending would ask for `"//"` and report every child as outside (issue #719).
|
|
77
|
+
* That made the bidirectional overlap guard in `validatePath` fail open in both
|
|
78
|
+
* directions once any project's working dir was `/`, so two keepers could be
|
|
79
|
+
* created with overlapping working trees. Append the separator only when the
|
|
80
|
+
* parent doesn't already end in one.
|
|
75
81
|
*/
|
|
76
82
|
export declare function isPathInside(child: string, parent: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Directories beneath which a project may never be linked (issue #720) — the
|
|
85
|
+
* operating system rather than anybody's work.
|
|
86
|
+
*
|
|
87
|
+
* Each entry denies itself and everything under it. The filesystem root is
|
|
88
|
+
* handled separately in {@link isSystemPath}: it is refused by exact match, not
|
|
89
|
+
* by containment, because since #719 EVERY absolute path is "inside" `/`.
|
|
90
|
+
*
|
|
91
|
+
* This is a floor, not an allow-list. The alternative the issue floated —
|
|
92
|
+
* requiring linked paths to sit under a configurable allowed root — is a bigger
|
|
93
|
+
* lever than the problem needs: it adds a config dimension and would invalidate
|
|
94
|
+
* every already-linked project the day it shipped. A denylist only ever refuses
|
|
95
|
+
* paths nobody legitimately links a project at.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
|
|
98
|
+
* each is somewhere a real checkout or notebook plausibly lives (`/var` in
|
|
99
|
+
* particular holds Paddock's own data dir on some boxes, and macOS temp dirs
|
|
100
|
+
* canonicalise under `/private/var`).
|
|
101
|
+
*
|
|
102
|
+
* On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
|
|
103
|
+
* {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
|
|
104
|
+
* checking, so those are caught by the `/usr` entry too. They are listed anyway
|
|
105
|
+
* for the systems where they are real directories.
|
|
106
|
+
*/
|
|
107
|
+
export declare const SYSTEM_PATH_DENYLIST: readonly ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32", "/proc", "/run", "/sbin", "/sys", "/usr"];
|
|
108
|
+
/**
|
|
109
|
+
* Whether a (resolved) path is the filesystem root or sits inside a system
|
|
110
|
+
* directory — the floor beneath which {@link
|
|
111
|
+
* import("./projects.js").ProjectStore.validatePath} refuses to link a project
|
|
112
|
+
* (issue #720).
|
|
113
|
+
*
|
|
114
|
+
* This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
|
|
115
|
+
* projects API can already create projects and run turns as whoever Paddock runs
|
|
116
|
+
* as. It exists because the failure is silent and the blast radius is large — a
|
|
117
|
+
* linked project's directory becomes a keeper's cwd, that keeper runs
|
|
118
|
+
* `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
|
|
119
|
+
* directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
|
|
120
|
+
*/
|
|
121
|
+
export declare function isSystemPath(candidate: string): boolean;
|
|
77
122
|
/**
|
|
78
123
|
* Resolve a project's working directory (keeper cwd) from its metadata dir plus
|
|
79
124
|
* whichever backing field is set:
|
|
@@ -79,12 +79,76 @@ export function repoCheckoutName(repo) {
|
|
|
79
79
|
* (it does NOT follow symlinks — callers that care resolve them beforehand).
|
|
80
80
|
*
|
|
81
81
|
* The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
|
|
82
|
-
* `/data/projects-old` a child of `/data/projects`.
|
|
82
|
+
* `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
|
|
83
|
+
* parent whose string already ends in a separator — the filesystem root — where
|
|
84
|
+
* appending would ask for `"//"` and report every child as outside (issue #719).
|
|
85
|
+
* That made the bidirectional overlap guard in `validatePath` fail open in both
|
|
86
|
+
* directions once any project's working dir was `/`, so two keepers could be
|
|
87
|
+
* created with overlapping working trees. Append the separator only when the
|
|
88
|
+
* parent doesn't already end in one.
|
|
83
89
|
*/
|
|
84
90
|
export function isPathInside(child, parent) {
|
|
85
91
|
const c = path.resolve(child);
|
|
86
92
|
const p = path.resolve(parent);
|
|
87
|
-
return c === p || c.startsWith(p + path.sep);
|
|
93
|
+
return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Directories beneath which a project may never be linked (issue #720) — the
|
|
97
|
+
* operating system rather than anybody's work.
|
|
98
|
+
*
|
|
99
|
+
* Each entry denies itself and everything under it. The filesystem root is
|
|
100
|
+
* handled separately in {@link isSystemPath}: it is refused by exact match, not
|
|
101
|
+
* by containment, because since #719 EVERY absolute path is "inside" `/`.
|
|
102
|
+
*
|
|
103
|
+
* This is a floor, not an allow-list. The alternative the issue floated —
|
|
104
|
+
* requiring linked paths to sit under a configurable allowed root — is a bigger
|
|
105
|
+
* lever than the problem needs: it adds a config dimension and would invalidate
|
|
106
|
+
* every already-linked project the day it shipped. A denylist only ever refuses
|
|
107
|
+
* paths nobody legitimately links a project at.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
|
|
110
|
+
* each is somewhere a real checkout or notebook plausibly lives (`/var` in
|
|
111
|
+
* particular holds Paddock's own data dir on some boxes, and macOS temp dirs
|
|
112
|
+
* canonicalise under `/private/var`).
|
|
113
|
+
*
|
|
114
|
+
* On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
|
|
115
|
+
* {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
|
|
116
|
+
* checking, so those are caught by the `/usr` entry too. They are listed anyway
|
|
117
|
+
* for the systems where they are real directories.
|
|
118
|
+
*/
|
|
119
|
+
export const SYSTEM_PATH_DENYLIST = [
|
|
120
|
+
"/bin",
|
|
121
|
+
"/boot",
|
|
122
|
+
"/dev",
|
|
123
|
+
"/etc",
|
|
124
|
+
"/lib",
|
|
125
|
+
"/lib32",
|
|
126
|
+
"/lib64",
|
|
127
|
+
"/libx32",
|
|
128
|
+
"/proc",
|
|
129
|
+
"/run",
|
|
130
|
+
"/sbin",
|
|
131
|
+
"/sys",
|
|
132
|
+
"/usr",
|
|
133
|
+
];
|
|
134
|
+
/**
|
|
135
|
+
* Whether a (resolved) path is the filesystem root or sits inside a system
|
|
136
|
+
* directory — the floor beneath which {@link
|
|
137
|
+
* import("./projects.js").ProjectStore.validatePath} refuses to link a project
|
|
138
|
+
* (issue #720).
|
|
139
|
+
*
|
|
140
|
+
* This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
|
|
141
|
+
* projects API can already create projects and run turns as whoever Paddock runs
|
|
142
|
+
* as. It exists because the failure is silent and the blast radius is large — a
|
|
143
|
+
* linked project's directory becomes a keeper's cwd, that keeper runs
|
|
144
|
+
* `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
|
|
145
|
+
* directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
|
|
146
|
+
*/
|
|
147
|
+
export function isSystemPath(candidate) {
|
|
148
|
+
const p = path.resolve(candidate);
|
|
149
|
+
if (p === path.parse(p).root)
|
|
150
|
+
return true;
|
|
151
|
+
return SYSTEM_PATH_DENYLIST.some((root) => isPathInside(p, root));
|
|
88
152
|
}
|
|
89
153
|
/**
|
|
90
154
|
* Resolve a project's working directory (keeper cwd) from its metadata dir plus
|
|
@@ -412,3 +412,26 @@ export type UpdateProjectInput = Partial<Pick<ProjectYaml, "name" | "status" | "
|
|
|
412
412
|
*/
|
|
413
413
|
curation?: CurationOverride | null;
|
|
414
414
|
};
|
|
415
|
+
/**
|
|
416
|
+
* The plain metadata keys a PATCH may set — the RUNTIME half of
|
|
417
|
+
* {@link UpdateProjectInput} (issue #721).
|
|
418
|
+
*
|
|
419
|
+
* The type is a compile-time shape; the request body is not type-checked at all,
|
|
420
|
+
* so `update()` used to spread it straight into the record it persisted and any
|
|
421
|
+
* invented key landed in `project.yaml` verbatim. This list is what it filters
|
|
422
|
+
* against.
|
|
423
|
+
*
|
|
424
|
+
* It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
|
|
425
|
+
* (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
|
|
426
|
+
* `attachments`/`curation`) are destructured out and applied explicitly, because
|
|
427
|
+
* a spread cannot express "delete this field" — which is how an override is
|
|
428
|
+
* cleared back to inherit — so they are patchable without being listed here.
|
|
429
|
+
*
|
|
430
|
+
* Not here, and not patchable by any route: `slug`/`started` (identity),
|
|
431
|
+
* `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
|
|
432
|
+
* three fields that decide the keeper's cwd (issues #206, #718).
|
|
433
|
+
*
|
|
434
|
+
* The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
|
|
435
|
+
* fails the build here rather than silently becoming un-patchable.
|
|
436
|
+
*/
|
|
437
|
+
export declare const PATCHABLE_KEYS: readonly ["name", "status", "domain", "group", "visibility", "summary", "links", "model", "permissionMode", "maxTurns", "docker"];
|
|
@@ -46,3 +46,38 @@ export function normalizeLinks(raw) {
|
|
|
46
46
|
}
|
|
47
47
|
return out;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The plain metadata keys a PATCH may set — the RUNTIME half of
|
|
51
|
+
* {@link UpdateProjectInput} (issue #721).
|
|
52
|
+
*
|
|
53
|
+
* The type is a compile-time shape; the request body is not type-checked at all,
|
|
54
|
+
* so `update()` used to spread it straight into the record it persisted and any
|
|
55
|
+
* invented key landed in `project.yaml` verbatim. This list is what it filters
|
|
56
|
+
* against.
|
|
57
|
+
*
|
|
58
|
+
* It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
|
|
59
|
+
* (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
|
|
60
|
+
* `attachments`/`curation`) are destructured out and applied explicitly, because
|
|
61
|
+
* a spread cannot express "delete this field" — which is how an override is
|
|
62
|
+
* cleared back to inherit — so they are patchable without being listed here.
|
|
63
|
+
*
|
|
64
|
+
* Not here, and not patchable by any route: `slug`/`started` (identity),
|
|
65
|
+
* `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
|
|
66
|
+
* three fields that decide the keeper's cwd (issues #206, #718).
|
|
67
|
+
*
|
|
68
|
+
* The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
|
|
69
|
+
* fails the build here rather than silently becoming un-patchable.
|
|
70
|
+
*/
|
|
71
|
+
export const PATCHABLE_KEYS = [
|
|
72
|
+
"name",
|
|
73
|
+
"status",
|
|
74
|
+
"domain",
|
|
75
|
+
"group",
|
|
76
|
+
"visibility",
|
|
77
|
+
"summary",
|
|
78
|
+
"links",
|
|
79
|
+
"model",
|
|
80
|
+
"permissionMode",
|
|
81
|
+
"maxTurns",
|
|
82
|
+
"docker",
|
|
83
|
+
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor } from "./project-mime.js";
|
|
2
2
|
export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
|
|
3
|
-
import { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
|
|
4
|
-
export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
3
|
+
import { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
|
|
4
|
+
export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
5
5
|
import { type Project, type FileKind, type FileEntry, type CreateProjectInput, type UpdateProjectInput } from "./project-types.js";
|
|
6
6
|
export * from "./project-types.js";
|
|
7
7
|
export declare class ProjectStore {
|
|
@@ -112,6 +112,9 @@ export declare class ProjectStore {
|
|
|
112
112
|
* - a relative path (a cwd must be unambiguous — it is baked into every
|
|
113
113
|
* transcript path, and resolving it against the server's cwd is a footgun);
|
|
114
114
|
* - a path that exists but isn't a directory;
|
|
115
|
+
* - the filesystem root, or a path inside a system directory (issue #720) —
|
|
116
|
+
* see {@link isSystemPath} for the floor and why it is a denylist rather
|
|
117
|
+
* than a configurable allowed root;
|
|
115
118
|
* - a path inside `projectsRoot` or the data dir — that is Paddock's own
|
|
116
119
|
* state, and pointing at it re-creates the nesting this feature exists to
|
|
117
120
|
* avoid (plus `remove()` would then really delete it);
|
|
@@ -36,10 +36,10 @@ import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "
|
|
|
36
36
|
// them at `./projects.js` — the same one-place-to-import discipline as before.
|
|
37
37
|
import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
|
|
38
38
|
export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
|
|
39
|
-
import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
40
|
-
export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
39
|
+
import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
40
|
+
export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
41
41
|
import { gitRemotes, normalizeRemote } from "./adoptable.js";
|
|
42
|
-
import { normalizeLinks, } from "./project-types.js";
|
|
42
|
+
import { normalizeLinks, PATCHABLE_KEYS, } from "./project-types.js";
|
|
43
43
|
import * as projectFiles from "./project-files.js";
|
|
44
44
|
// Barrel: re-export all type declarations + normalizeLinks + the config-type
|
|
45
45
|
// re-exports (PaddockSchedule/PaddockHook/PaddockTrigger/RecoveryOverride/
|
|
@@ -221,7 +221,12 @@ export class ProjectStore {
|
|
|
221
221
|
async rmInsideRoot(target) {
|
|
222
222
|
const resolved = path.resolve(target);
|
|
223
223
|
const root = path.resolve(this.root);
|
|
224
|
-
|
|
224
|
+
// A projects root of `/` would make every path on the box "inside" it and
|
|
225
|
+
// turn this guard into a no-op. That was accidentally safe before #719 (when
|
|
226
|
+
// `isPathInside(x, "/")` was false for everything); now that containment
|
|
227
|
+
// reports the truth, refuse the degenerate root explicitly rather than
|
|
228
|
+
// relying on a bug to hold the line.
|
|
229
|
+
if (root === path.parse(root).root || resolved === root || !isPathInside(resolved, root)) {
|
|
225
230
|
throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
|
|
226
231
|
}
|
|
227
232
|
await fs.rm(resolved, { recursive: true, force: true });
|
|
@@ -259,6 +264,9 @@ export class ProjectStore {
|
|
|
259
264
|
* - a relative path (a cwd must be unambiguous — it is baked into every
|
|
260
265
|
* transcript path, and resolving it against the server's cwd is a footgun);
|
|
261
266
|
* - a path that exists but isn't a directory;
|
|
267
|
+
* - the filesystem root, or a path inside a system directory (issue #720) —
|
|
268
|
+
* see {@link isSystemPath} for the floor and why it is a denylist rather
|
|
269
|
+
* than a configurable allowed root;
|
|
262
270
|
* - a path inside `projectsRoot` or the data dir — that is Paddock's own
|
|
263
271
|
* state, and pointing at it re-creates the nesting this feature exists to
|
|
264
272
|
* avoid (plus `remove()` would then really delete it);
|
|
@@ -297,6 +305,18 @@ export class ProjectStore {
|
|
|
297
305
|
throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
|
|
298
306
|
}
|
|
299
307
|
}
|
|
308
|
+
// The system-path floor (issue #720), applied to BOTH spellings.
|
|
309
|
+
//
|
|
310
|
+
// The canonicalised path is the one that matters — a symlink pointing at
|
|
311
|
+
// `/etc` must be refused for where it really goes. But the path AS WRITTEN
|
|
312
|
+
// has to be checked too, because `/proc/self/cwd` canonicalises to an
|
|
313
|
+
// ordinary directory (Paddock's own checkout) and so passes the resolved
|
|
314
|
+
// check: a `/proc` path is process-relative and non-durable, which a cwd
|
|
315
|
+
// baked into every transcript path can never be.
|
|
316
|
+
if (isSystemPath(resolved) || isSystemPath(input)) {
|
|
317
|
+
throw new ProjectError(`Project path is a system directory and cannot back a project: ${input}` +
|
|
318
|
+
(resolved === path.resolve(input) ? "" : ` (resolves to ${resolved})`), "invalid");
|
|
319
|
+
}
|
|
300
320
|
if (isPathInside(resolved, this.root)) {
|
|
301
321
|
throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
|
|
302
322
|
}
|
|
@@ -699,30 +719,64 @@ export class ProjectStore {
|
|
|
699
719
|
// can't express "delete this field", which is how an override is cleared back
|
|
700
720
|
// to inherit.
|
|
701
721
|
const { driveMode: driveModePatch, maxSpawnDepth: maxSpawnDepthPatch, hooksMcpEnabled: hooksMcpPatch, recovery: recoveryPatch, attachments: attachmentsPatch, curation: curationPatch, models: modelsPatch, ...rest } = patch;
|
|
722
|
+
// Take ONLY the allowlisted keys off the body (issue #721). `rest` is spread
|
|
723
|
+
// from an untrusted request body, so the previous `...rest` persisted any key
|
|
724
|
+
// a caller invented — verbatim, unbounded, into a file re-parsed on every
|
|
725
|
+
// project listing — and a key seeded with garbage today is a landmine for the
|
|
726
|
+
// release that starts honouring it.
|
|
727
|
+
const unknown = [];
|
|
728
|
+
const patched = {};
|
|
729
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
730
|
+
if (value === undefined)
|
|
731
|
+
continue;
|
|
732
|
+
if (!PATCHABLE_KEYS.includes(key)) {
|
|
733
|
+
unknown.push(key);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
patched[key] = value;
|
|
737
|
+
}
|
|
738
|
+
if (unknown.length > 0) {
|
|
739
|
+
this.log.warn(`Ignoring unpatchable key(s) in PATCH of project "${slug}": ${unknown.join(", ")}`);
|
|
740
|
+
}
|
|
702
741
|
const next = {
|
|
703
742
|
...this.stripDto(current),
|
|
704
|
-
...
|
|
743
|
+
...patched,
|
|
705
744
|
slug: current.slug, // immutable
|
|
706
745
|
started: current.started, // immutable
|
|
707
746
|
updated: today(),
|
|
708
747
|
};
|
|
709
|
-
// `path` and `
|
|
710
|
-
//
|
|
711
|
-
//
|
|
748
|
+
// `path`, `managed` and `repo` are immutable — the THREE fields that feed
|
|
749
|
+
// `workingDirFor()` and therefore decide the keeper's cwd. The allowlist above
|
|
750
|
+
// already keeps them out of `patched`; re-asserting them from `current` here
|
|
751
|
+
// is deliberate belt-and-braces at the point the record is built, so a key
|
|
752
|
+
// later added to the allowlist cannot silently move a cwd.
|
|
712
753
|
//
|
|
713
|
-
// For `path` both directions matter: a project must not be
|
|
714
|
-
// is baked into every transcript path, so moving it
|
|
715
|
-
// one must not be
|
|
716
|
-
// keeper a cwd somewhere else on the box that never went
|
|
717
|
-
// `validatePath`. `managed` is re-asserted
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
754
|
+
// For `path` both directions matter (issue #206): a project must not be
|
|
755
|
+
// RE-POINTED (its cwd is baked into every transcript path, so moving it
|
|
756
|
+
// strands the history), and one must not be GIVEN a path by a stray key,
|
|
757
|
+
// which would hand its keeper a cwd somewhere else on the box that never went
|
|
758
|
+
// through `validatePath`. `managed` is re-asserted for the same reason —
|
|
759
|
+
// flipping it moves where the curated trio lives and either starts the sweeper
|
|
760
|
+
// writing into a checkout or orphans the existing notes.
|
|
761
|
+
//
|
|
762
|
+
// `repo` was the one that got missed (issue #718). It is documented immutable
|
|
763
|
+
// (`ProjectYaml.repo`: "Set at creation; immutable thereafter"), `create()` and
|
|
764
|
+
// `promote()` both validate it with `isValidRepoUrl()`, and `update()` did
|
|
765
|
+
// neither: `PATCH {"repo":"not a url at all"}` returned 200 and relocated
|
|
766
|
+
// `workingDir` AND `contentDir` to `<dir>/not-a-url-at-all`, a directory that
|
|
767
|
+
// does not exist — every subsequent turn then hung 60s waiting for a session
|
|
768
|
+
// file and failed, with the existing chats stranded on the old cwd. Acquiring a
|
|
769
|
+
// repo for an existing project is what `promote()` is for; changing a backing
|
|
770
|
+
// store is a migration, not a PATCH (see #708 for what a silent location flip
|
|
771
|
+
// does to history).
|
|
722
772
|
if (current.path)
|
|
723
773
|
next.path = current.path;
|
|
724
774
|
else
|
|
725
775
|
delete next.path;
|
|
776
|
+
if (current.repo)
|
|
777
|
+
next.repo = current.repo;
|
|
778
|
+
else
|
|
779
|
+
delete next.repo;
|
|
726
780
|
next.managed = current.managed;
|
|
727
781
|
if (driveModePatch === null) {
|
|
728
782
|
// Clear the per-project override -> inherit the global default (issue #122).
|
|
@@ -12,9 +12,22 @@ export interface QueuedPart {
|
|
|
12
12
|
id: string;
|
|
13
13
|
text: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* A composer attachment staged on the slot (#728) — the same lightweight ref the
|
|
17
|
+
* `chat:send` path carries. The bytes live in the AttachmentStore; only the id,
|
|
18
|
+
* filename and kind travel with the queued message.
|
|
19
|
+
*/
|
|
20
|
+
export interface QueuedAttachment {
|
|
21
|
+
id: string;
|
|
22
|
+
filename: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
}
|
|
15
25
|
/** A queued message awaiting auto-send. */
|
|
16
26
|
export interface QueuedMessage {
|
|
17
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The slot's full text — what gets sent. May be `""` when the slot holds only
|
|
29
|
+
* attachments (#728: an attachment-only message is valid, #328).
|
|
30
|
+
*/
|
|
18
31
|
text: string;
|
|
19
32
|
/** SERVER-stamped enqueue time. Informational — never load-bearing (#736). */
|
|
20
33
|
createdAtMs: number;
|
|
@@ -30,7 +43,22 @@ export interface QueuedMessage {
|
|
|
30
43
|
* entry written by an older server; readers treat that as one legacy identity.
|
|
31
44
|
*/
|
|
32
45
|
parts?: QueuedPart[];
|
|
46
|
+
/**
|
|
47
|
+
* Files staged in the composer that ride this message when it drains (#728).
|
|
48
|
+
* Merged as a UNION BY ID so a write can only ever add: a client re-asserting
|
|
49
|
+
* its queue after a reload has an empty tray by then, and must not be able to
|
|
50
|
+
* wipe the files off the shared slot.
|
|
51
|
+
*/
|
|
52
|
+
attachments?: QueuedAttachment[];
|
|
33
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Union two attachment lists by id, preserving order and preferring the entry
|
|
56
|
+
* already on the slot (its filename/kind is the one every client has been shown).
|
|
57
|
+
* Bounded by {@link MAX_SLOT_ATTACHMENTS}.
|
|
58
|
+
*/
|
|
59
|
+
export declare function unionAttachments(current: QueuedAttachment[] | undefined, incoming: QueuedAttachment[] | undefined): QueuedAttachment[];
|
|
60
|
+
/** Does this slot hold anything worth sending? Text, attachments, or both. */
|
|
61
|
+
export declare function hasQueuedContent(m: QueuedMessage | null | undefined): boolean;
|
|
34
62
|
/** The identities of an entry, synthesising one for a legacy entry without any. */
|
|
35
63
|
export declare function partsOf(m: QueuedMessage): QueuedPart[];
|
|
36
64
|
export declare class QueuedMessageStore {
|
|
@@ -86,7 +114,9 @@ export declare class QueuedMessageStore {
|
|
|
86
114
|
* `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
|
|
87
115
|
* the one it has. The client's clock never enters the store (#736).
|
|
88
116
|
*/
|
|
89
|
-
upsert(agent: string, sessionId: string, incoming: QueuedPart
|
|
117
|
+
upsert(agent: string, sessionId: string, incoming: QueuedPart & {
|
|
118
|
+
attachments?: QueuedAttachment[];
|
|
119
|
+
}, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
|
|
90
120
|
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
91
121
|
private persist;
|
|
92
122
|
}
|
|
@@ -49,6 +49,58 @@ function keyOf(agent, sessionId) {
|
|
|
49
49
|
const MERGE_SEP = "\n";
|
|
50
50
|
/** How many (id, text) identities one slot remembers. Bounds the sidecar entry. */
|
|
51
51
|
const MAX_KNOWN = 24;
|
|
52
|
+
/**
|
|
53
|
+
* How many attachments one slot can hold (#728). The composer caps a single
|
|
54
|
+
* message at 10 by default, but the slot is shared and merges additively, so two
|
|
55
|
+
* devices could otherwise stack refs into the sidecar without bound. Well above
|
|
56
|
+
* any real queued follow-up; the excess is dropped rather than persisted.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_SLOT_ATTACHMENTS = 20;
|
|
59
|
+
/**
|
|
60
|
+
* Union two attachment lists by id, preserving order and preferring the entry
|
|
61
|
+
* already on the slot (its filename/kind is the one every client has been shown).
|
|
62
|
+
* Bounded by {@link MAX_SLOT_ATTACHMENTS}.
|
|
63
|
+
*/
|
|
64
|
+
export function unionAttachments(current, incoming) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const a of [...(current ?? []), ...(incoming ?? [])]) {
|
|
68
|
+
if (!a || typeof a.id !== "string" || !a.id || seen.has(a.id))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(a.id);
|
|
71
|
+
out.push(a.kind === undefined ? { id: a.id, filename: a.filename } : { ...a });
|
|
72
|
+
if (out.length >= MAX_SLOT_ATTACHMENTS)
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Narrow a parsed value to a well-formed attachment list (dropping junk). */
|
|
78
|
+
function sanitizeAttachments(v) {
|
|
79
|
+
if (!Array.isArray(v))
|
|
80
|
+
return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const a of v) {
|
|
83
|
+
if (!a || typeof a !== "object")
|
|
84
|
+
continue;
|
|
85
|
+
const o = a;
|
|
86
|
+
if (typeof o.id !== "string" || !o.id)
|
|
87
|
+
continue;
|
|
88
|
+
if (typeof o.filename !== "string" || !o.filename)
|
|
89
|
+
continue;
|
|
90
|
+
out.push({
|
|
91
|
+
id: o.id,
|
|
92
|
+
filename: o.filename,
|
|
93
|
+
...(typeof o.kind === "string" ? { kind: o.kind } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Does this slot hold anything worth sending? Text, attachments, or both. */
|
|
99
|
+
export function hasQueuedContent(m) {
|
|
100
|
+
if (!m)
|
|
101
|
+
return false;
|
|
102
|
+
return m.text.length > 0 || (m.attachments?.length ?? 0) > 0;
|
|
103
|
+
}
|
|
52
104
|
/**
|
|
53
105
|
* Fold a write from a client that ISN'T on the slot's current version into the
|
|
54
106
|
* slot's text.
|
|
@@ -120,11 +172,16 @@ export class QueuedMessageStore {
|
|
|
120
172
|
typeof p.text === "string")
|
|
121
173
|
: [];
|
|
122
174
|
const id = typeof v.id === "string" ? entry.id : undefined;
|
|
175
|
+
// Same tolerance for the staged attachments (#728): malformed entries
|
|
176
|
+
// are dropped, never thrown on — a corrupt sidecar must not be able to
|
|
177
|
+
// stop the queue loading.
|
|
178
|
+
const attachments = sanitizeAttachments(v.attachments);
|
|
123
179
|
map.set(k, {
|
|
124
180
|
text: entry.text,
|
|
125
181
|
createdAtMs: entry.createdAtMs,
|
|
126
182
|
...(id ? { id } : {}),
|
|
127
183
|
...(parts.length > 0 ? { parts } : {}),
|
|
184
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
128
185
|
});
|
|
129
186
|
}
|
|
130
187
|
}
|
|
@@ -208,29 +265,51 @@ export class QueuedMessageStore {
|
|
|
208
265
|
const map = await this.ensureLoaded();
|
|
209
266
|
const key = keyOf(agent, sessionId);
|
|
210
267
|
const current = map.get(key) ?? null;
|
|
211
|
-
|
|
268
|
+
// Attachments merge additively and independently of the text (#728): a write
|
|
269
|
+
// can add files, never remove them. Computed first so an attachments-only
|
|
270
|
+
// write — a valid message with no prose (#328) — has something to write even
|
|
271
|
+
// though every text rule below leaves the text alone.
|
|
272
|
+
const attachments = unionAttachments(current?.attachments, incoming.attachments);
|
|
273
|
+
const gainedAttachments = attachments.length > (current?.attachments?.length ?? 0);
|
|
274
|
+
const hasText = incoming.text.trim().length > 0;
|
|
275
|
+
if (!hasText && !gainedAttachments)
|
|
212
276
|
return current;
|
|
213
277
|
const known = current ? partsOf(current) : [];
|
|
214
|
-
// Already folded in under some identity ⇒ a re-assert, not new text.
|
|
215
|
-
if
|
|
278
|
+
// Already folded in under some identity ⇒ a re-assert, not new text. Still
|
|
279
|
+
// worth writing if it brought new files with it.
|
|
280
|
+
if (hasText && known.some((p) => p.text === incoming.text) && !gainedAttachments) {
|
|
216
281
|
return current;
|
|
282
|
+
}
|
|
283
|
+
const textIsNew = hasText && !known.some((p) => p.text === incoming.text);
|
|
217
284
|
const text = !current
|
|
218
285
|
? incoming.text
|
|
219
|
-
:
|
|
220
|
-
? //
|
|
221
|
-
|
|
222
|
-
|
|
286
|
+
: !textIsNew
|
|
287
|
+
? // Nothing new to say — an attachments-only write, or a re-assert that
|
|
288
|
+
// brought files. The slot's prose is untouched.
|
|
289
|
+
current.text
|
|
290
|
+
: incoming.id === current.id
|
|
291
|
+
? // The client can see the slot as it stands, so its text IS the slot.
|
|
292
|
+
incoming.text
|
|
293
|
+
: mergeInto(current.text, known, incoming.text);
|
|
223
294
|
const version = mintVersion();
|
|
224
295
|
// Remember BOTH identities this write creates: what the client sent (which it
|
|
225
296
|
// will re-assert on reload) and the version we hand back (which it will
|
|
226
297
|
// re-assert once it adopts the broadcast). Either one reaching a later drain
|
|
227
298
|
// has to be recognisable as already-flushed.
|
|
228
|
-
|
|
299
|
+
// An attachments-only write contributes no text, so it contributes no identity
|
|
300
|
+
// to remember either — an empty-text part would match nothing and only crowd
|
|
301
|
+
// the ledger. The slot version is always recorded.
|
|
302
|
+
const parts = [
|
|
303
|
+
...known,
|
|
304
|
+
...(hasText ? [{ id: incoming.id, text: incoming.text }] : []),
|
|
305
|
+
{ id: version, text },
|
|
306
|
+
].slice(-MAX_KNOWN);
|
|
229
307
|
const next = {
|
|
230
308
|
text,
|
|
231
309
|
createdAtMs: current?.createdAtMs ?? nowMs,
|
|
232
310
|
id: version,
|
|
233
311
|
parts,
|
|
312
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
234
313
|
};
|
|
235
314
|
map.set(key, next);
|
|
236
315
|
await this.persist(map);
|