@edspencer/paddock 0.71.2 → 0.72.1
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/app.js +27 -0
- package/packages/server/dist/cli/args.d.ts +56 -5
- package/packages/server/dist/cli/args.js +193 -31
- package/packages/server/dist/cli/config.d.ts +157 -0
- package/packages/server/dist/cli/config.js +462 -0
- package/packages/server/dist/cli/paddock.js +53 -3
- package/packages/server/dist/config.d.ts +36 -1
- package/packages/server/dist/config.js +59 -44
- package/packages/server/dist/herdctl.d.ts +45 -0
- package/packages/server/dist/herdctl.js +58 -4
- package/packages/server/dist/instance-config.d.ts +149 -21
- package/packages/server/dist/instance-config.js +228 -5
- package/packages/server/dist/last-activity.d.ts +21 -0
- package/packages/server/dist/last-activity.js +7 -2
- package/packages/server/dist/profiles.d.ts +154 -0
- package/packages/server/dist/profiles.js +214 -0
- package/packages/server/dist/routes/transcripts.d.ts +61 -0
- package/packages/server/dist/routes/transcripts.js +676 -0
- package/packages/server/dist/routes.d.ts +3 -0
- package/packages/server/dist/routes.js +2 -0
- package/packages/server/dist/transcript-move.d.ts +62 -0
- package/packages/server/dist/transcript-move.js +179 -0
- package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
- package/packages/server/dist/transcripts-migration-execute.js +630 -0
- package/packages/server/dist/transcripts-migration.d.ts +336 -0
- package/packages/server/dist/transcripts-migration.js +917 -0
- package/packages/server/dist/transcripts.d.ts +46 -2
- package/packages/server/dist/transcripts.js +126 -3
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +2 -0
- package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
- package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
- package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
- package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
- package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
- package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
- package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
- package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
- package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
- package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
- package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
- package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
- package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
- package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
- package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
- package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
- package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
- package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
- package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
- package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
- package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* git — /api/git[/push|/github/*], project git status/diff/commit
|
|
17
17
|
* projects — projects CRUD + promote, pins, files/changelog/overview/commands
|
|
18
18
|
* discover — /api/discover, /api/discover/sessions (instance-level, #745)
|
|
19
|
+
* transcripts — GET /api/transcripts/migration[/chats] (the own→host migration
|
|
20
|
+
* probe + plan) and POST /api/transcripts/migration (execute)
|
|
21
|
+
* (instance-level, #882)
|
|
19
22
|
* triggers — project triggers CRUD + runtime + run-now
|
|
20
23
|
* chats — workspace chat lifecycle (list/runs/usage/create/messages/
|
|
21
24
|
* subagents/context/delete/rename/fork/archive/star/seen/promote)
|
|
@@ -6,6 +6,7 @@ import { registerTriggerWorkspaceRoutes } from "./routes/triggers.js";
|
|
|
6
6
|
import { registerChatWorkspaceRoutes } from "./routes/chats.js";
|
|
7
7
|
import { registerMcpRoutes } from "./routes/mcp.js";
|
|
8
8
|
import { registerDiscoverRoutes } from "./routes/discover.js";
|
|
9
|
+
import { registerTranscriptsRoutes } from "./routes/transcripts.js";
|
|
9
10
|
import { mountWorkspaceRoutes } from "./routes/workspace-mount.js";
|
|
10
11
|
export { parseRangeHeader } from "./http-bytes.js";
|
|
11
12
|
export async function registerRoutes(app, deps) {
|
|
@@ -14,6 +15,7 @@ export async function registerRoutes(app, deps) {
|
|
|
14
15
|
registerGitRoutes(app, ctx);
|
|
15
16
|
registerProjectRoutes(app, ctx);
|
|
16
17
|
registerDiscoverRoutes(app, ctx);
|
|
18
|
+
registerTranscriptsRoutes(app, ctx);
|
|
17
19
|
// The workspace-scoped half of every group, mounted twice: `/api/root` (the
|
|
18
20
|
// root workspace, key `""`) and `/api/projects/:slug` (a project). Same
|
|
19
21
|
// handlers both times — that identity is the point, not an optimisation.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Thrown rather than clobbering — see the module doc's property 2. */
|
|
2
|
+
export declare class DestinationExistsError extends Error {
|
|
3
|
+
readonly destination: string;
|
|
4
|
+
constructor(destination: string);
|
|
5
|
+
}
|
|
6
|
+
/** Does anything at all live at `p` — including a broken symlink? */
|
|
7
|
+
export declare function pathExists(p: string): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Move one file or directory tree `from` → `to`, without ever overwriting.
|
|
10
|
+
*
|
|
11
|
+
* The caller owns the collision policy: this refuses, it does not invent a
|
|
12
|
+
* name. See {@link uniqueDestination} for the migration's answer.
|
|
13
|
+
*/
|
|
14
|
+
export declare function moveEntry(from: string, to: string): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* A free path at `dir/<name>`, suffixing `.1`, `.2`, … when taken.
|
|
17
|
+
*
|
|
18
|
+
* Used only for the PRESERVE side. A preserve dir can already hold a copy from
|
|
19
|
+
* an earlier run of a migration that failed partway, and "nothing is ever
|
|
20
|
+
* deleted" has to hold across retries too — so the second copy is set beside
|
|
21
|
+
* the first rather than on top of it. The real path used is what the response
|
|
22
|
+
* reports, so the user is never sent to a path that holds someone else's file.
|
|
23
|
+
*/
|
|
24
|
+
export declare function uniqueDestination(dir: string, name: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Everything in a store that belongs to one session id, as store-RELATIVE
|
|
27
|
+
* paths so the same list can be replayed against a destination.
|
|
28
|
+
*
|
|
29
|
+
* `.reverts/` is prefix-matched longest-first rather than split at a fixed
|
|
30
|
+
* offset: a session id is only guaranteed to match `[A-Za-z0-9._-]+`, so
|
|
31
|
+
* assuming a 36-char UUID mis-files every revert belonging to a chat named
|
|
32
|
+
* anything else, and a chat whose id is a prefix of another's would steal its
|
|
33
|
+
* neighbour's reverts.
|
|
34
|
+
*/
|
|
35
|
+
export interface ChatArtifacts {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
/** Store-relative paths, in the order they should be moved. */
|
|
38
|
+
relative: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* List the artifacts of `sessionId` that actually exist in `store`.
|
|
42
|
+
*
|
|
43
|
+
* `revertNames` is passed in rather than read here because the migration reads
|
|
44
|
+
* `.reverts/` once per project and groups it, and a per-chat readdir of a
|
|
45
|
+
* directory shared by 500 chats is 500 readdirs of the same directory.
|
|
46
|
+
*/
|
|
47
|
+
export declare function collectChatArtifacts(store: string, sessionId: string, revertNames?: readonly string[]): Promise<ChatArtifacts>;
|
|
48
|
+
/** Which chat a `.reverts/<id>-<stamp>.jsonl` belongs to; see {@link ChatArtifacts}. */
|
|
49
|
+
export declare function revertOwner(name: string, chatIds: ReadonlySet<string>): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Remove `dir` and every directory under it that holds no files, bottom-up.
|
|
52
|
+
*
|
|
53
|
+
* Not a delete of anything the user has: it clears the *structure* left behind
|
|
54
|
+
* once a directory's contents have been moved out — `.reverts/` after its
|
|
55
|
+
* snapshots have gone, `memory/` after its files have been merged. Without it
|
|
56
|
+
* those empty shells keep `.chats/` non-empty, `pointChatsDirAt` declines the
|
|
57
|
+
* redirect symlink, and the migration fails its own postcondition on a
|
|
58
|
+
* directory containing nothing at all.
|
|
59
|
+
*
|
|
60
|
+
* Anything holding a file is left completely alone.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pruneEmptyTree(dir: string): Promise<void>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a chat's files from one transcript store to another — the shared
|
|
3
|
+
* primitive under the `own → host` migration (#882).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a module and not four lines inside the migration
|
|
6
|
+
*
|
|
7
|
+
* A "chat" is not one file. On this box's real stores a single session id owns
|
|
8
|
+
* up to four separate things:
|
|
9
|
+
*
|
|
10
|
+
* `<id>.jsonl` the transcript
|
|
11
|
+
* `<id>/subagents/` sidechain transcripts
|
|
12
|
+
* `<id>/tool-results/` spilled tool output
|
|
13
|
+
* `.reverts/<id>-*.jsonl` revert snapshots, in a directory SHARED by every
|
|
14
|
+
* session in the store (`herdctl.ts:1988`)
|
|
15
|
+
*
|
|
16
|
+
* `promoteSession` (`herdctl.ts:1814-1852`) moves only the first of those, and
|
|
17
|
+
* therefore silently orphans the rest — the `<id>/` directory and the reverts
|
|
18
|
+
* stay in the source store, pointing at a chat that is no longer there. That is
|
|
19
|
+
* a pre-existing bug, **filed as #898**, and this helper is its fix: promote
|
|
20
|
+
* should adopt {@link collectChatArtifacts} + {@link moveArtifacts} rather than
|
|
21
|
+
* grow a second copy of the enumeration. Deliberately NOT changed here — see
|
|
22
|
+
* `docs/DESIGN-transcripts-migration.md` §10.6 for why it is a follow-up
|
|
23
|
+
* (promote has its own failure modes and its own tests, and reviewing them as a
|
|
24
|
+
* footnote to a large feature is how a regression gets in).
|
|
25
|
+
*
|
|
26
|
+
* ## The two properties everything else rests on
|
|
27
|
+
*
|
|
28
|
+
* 1. **Nothing is ever deleted.** The migration's whole promise to the user is
|
|
29
|
+
* that a copy they disagree with is set aside, not destroyed. So there is no
|
|
30
|
+
* unlink in this module that is not the second half of a copy.
|
|
31
|
+
*
|
|
32
|
+
* 2. **A destination that exists is never written through.** This is the
|
|
33
|
+
* load-bearing one, and it is not automatic: POSIX `rename(2)` *silently
|
|
34
|
+
* replaces* an existing destination file. `mv a b` overwriting `b` is the
|
|
35
|
+
* behaviour every caller would get by default, and on the `host` side `b` is
|
|
36
|
+
* a file in the user's real `~/.claude`. {@link moveArtifacts} therefore
|
|
37
|
+
* `lstat`s every destination first and refuses with
|
|
38
|
+
* {@link DestinationExistsError}; the migration's own rule is to move the
|
|
39
|
+
* superseded copy to the preserve dir BEFORE calling in here, so this guard
|
|
40
|
+
* should never fire — which is exactly why it has to exist and be tested.
|
|
41
|
+
*
|
|
42
|
+
* ## `rename` first, copy on `EXDEV`
|
|
43
|
+
*
|
|
44
|
+
* `ensureProjectChats:352-362` unconditionally does `cp` + `rm` with the note
|
|
45
|
+
* *"rename would EXDEV across mounts"*. Correct, and it leaves a 53× speedup on
|
|
46
|
+
* the floor in the overwhelmingly common case where the data dir and
|
|
47
|
+
* `~/.claude` share a filesystem (measured in design §6: 24 ms vs 1.3 s for 500
|
|
48
|
+
* chats). So: try `rename`, and fall back only on the errors that actually mean
|
|
49
|
+
* "different device". `preserveTimestamps: true` on the copy path is load-bearing
|
|
50
|
+
* rather than cosmetic (#588): a transcript's mtime is the cache key for
|
|
51
|
+
* auto-name, preview and sidechain detection, and `fs.cp` stamps NOW by default.
|
|
52
|
+
*/
|
|
53
|
+
import { promises as fs } from "node:fs";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
/**
|
|
56
|
+
* The errors that mean "these paths are not on the same filesystem, `rename`
|
|
57
|
+
* cannot help you". `EXDEV` is the POSIX one; `ENOTSUP`/`EPERM` show up from
|
|
58
|
+
* some FUSE and overlay mounts (docker's overlayfs, sshfs) for the same reason.
|
|
59
|
+
*/
|
|
60
|
+
const CROSS_DEVICE = new Set(["EXDEV", "ENOTSUP", "EPERM"]);
|
|
61
|
+
/** Thrown rather than clobbering — see the module doc's property 2. */
|
|
62
|
+
export class DestinationExistsError extends Error {
|
|
63
|
+
destination;
|
|
64
|
+
constructor(destination) {
|
|
65
|
+
super(`refusing to overwrite an existing path: ${destination}`);
|
|
66
|
+
this.destination = destination;
|
|
67
|
+
this.name = "DestinationExistsError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Does anything at all live at `p` — including a broken symlink? */
|
|
71
|
+
export async function pathExists(p) {
|
|
72
|
+
return fs
|
|
73
|
+
.lstat(p)
|
|
74
|
+
.then(() => true)
|
|
75
|
+
.catch(() => false);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Move one file or directory tree `from` → `to`, without ever overwriting.
|
|
79
|
+
*
|
|
80
|
+
* The caller owns the collision policy: this refuses, it does not invent a
|
|
81
|
+
* name. See {@link uniqueDestination} for the migration's answer.
|
|
82
|
+
*/
|
|
83
|
+
export async function moveEntry(from, to) {
|
|
84
|
+
if (await pathExists(to))
|
|
85
|
+
throw new DestinationExistsError(to);
|
|
86
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
87
|
+
try {
|
|
88
|
+
await fs.rename(from, to);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const code = err.code ?? "";
|
|
93
|
+
if (!CROSS_DEVICE.has(code))
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// Cross-device: copy then remove. If the copy throws, the source is still
|
|
97
|
+
// there and untouched — which is the failure mode we want, because the
|
|
98
|
+
// alternative is a half-moved chat with no copy anywhere.
|
|
99
|
+
await fs.cp(from, to, { recursive: true, preserveTimestamps: true });
|
|
100
|
+
await fs.rm(from, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A free path at `dir/<name>`, suffixing `.1`, `.2`, … when taken.
|
|
104
|
+
*
|
|
105
|
+
* Used only for the PRESERVE side. A preserve dir can already hold a copy from
|
|
106
|
+
* an earlier run of a migration that failed partway, and "nothing is ever
|
|
107
|
+
* deleted" has to hold across retries too — so the second copy is set beside
|
|
108
|
+
* the first rather than on top of it. The real path used is what the response
|
|
109
|
+
* reports, so the user is never sent to a path that holds someone else's file.
|
|
110
|
+
*/
|
|
111
|
+
export async function uniqueDestination(dir, name) {
|
|
112
|
+
const first = path.join(dir, name);
|
|
113
|
+
if (!(await pathExists(first)))
|
|
114
|
+
return first;
|
|
115
|
+
for (let n = 1; n < 1000; n++) {
|
|
116
|
+
const candidate = path.join(dir, `${name}.${n}`);
|
|
117
|
+
if (!(await pathExists(candidate)))
|
|
118
|
+
return candidate;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`no free destination for ${name} in ${dir} after 1000 tries`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* List the artifacts of `sessionId` that actually exist in `store`.
|
|
124
|
+
*
|
|
125
|
+
* `revertNames` is passed in rather than read here because the migration reads
|
|
126
|
+
* `.reverts/` once per project and groups it, and a per-chat readdir of a
|
|
127
|
+
* directory shared by 500 chats is 500 readdirs of the same directory.
|
|
128
|
+
*/
|
|
129
|
+
export async function collectChatArtifacts(store, sessionId, revertNames = []) {
|
|
130
|
+
const relative = [];
|
|
131
|
+
const transcript = `${sessionId}.jsonl`;
|
|
132
|
+
if (await pathExists(path.join(store, transcript)))
|
|
133
|
+
relative.push(transcript);
|
|
134
|
+
if (await pathExists(path.join(store, sessionId)))
|
|
135
|
+
relative.push(sessionId);
|
|
136
|
+
for (const name of revertNames) {
|
|
137
|
+
if (await pathExists(path.join(store, ".reverts", name))) {
|
|
138
|
+
relative.push(path.join(".reverts", name));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { sessionId, relative };
|
|
142
|
+
}
|
|
143
|
+
/** Which chat a `.reverts/<id>-<stamp>.jsonl` belongs to; see {@link ChatArtifacts}. */
|
|
144
|
+
export function revertOwner(name, chatIds) {
|
|
145
|
+
let best;
|
|
146
|
+
for (let i = name.indexOf("-"); i > 0; i = name.indexOf("-", i + 1)) {
|
|
147
|
+
const candidate = name.slice(0, i);
|
|
148
|
+
if (chatIds.has(candidate) && (best === undefined || candidate.length > best.length)) {
|
|
149
|
+
best = candidate;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Remove `dir` and every directory under it that holds no files, bottom-up.
|
|
156
|
+
*
|
|
157
|
+
* Not a delete of anything the user has: it clears the *structure* left behind
|
|
158
|
+
* once a directory's contents have been moved out — `.reverts/` after its
|
|
159
|
+
* snapshots have gone, `memory/` after its files have been merged. Without it
|
|
160
|
+
* those empty shells keep `.chats/` non-empty, `pointChatsDirAt` declines the
|
|
161
|
+
* redirect symlink, and the migration fails its own postcondition on a
|
|
162
|
+
* directory containing nothing at all.
|
|
163
|
+
*
|
|
164
|
+
* Anything holding a file is left completely alone.
|
|
165
|
+
*/
|
|
166
|
+
export async function pruneEmptyTree(dir) {
|
|
167
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
|
|
168
|
+
if (!entries)
|
|
169
|
+
return;
|
|
170
|
+
for (const e of entries) {
|
|
171
|
+
if (e.isDirectory())
|
|
172
|
+
await pruneEmptyTree(path.join(dir, e.name));
|
|
173
|
+
}
|
|
174
|
+
// `["?"]` on failure so an unreadable directory is never mistaken for an
|
|
175
|
+
// empty one and removed.
|
|
176
|
+
if ((await fs.readdir(dir).catch(() => ["?"])).length === 0) {
|
|
177
|
+
await fs.rmdir(dir).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type TranscriptsMode } from "./transcripts.js";
|
|
2
|
+
import { type MigrationInput, type MigrationState, type MigrationWarning } from "./transcripts-migration.js";
|
|
3
|
+
/**
|
|
4
|
+
* Why a copy ended up in the preserve dir rather than the host store.
|
|
5
|
+
*
|
|
6
|
+
* The design's enum was `["unchecked", "unplanned-diverged"]`, which only
|
|
7
|
+
* covers the cases where the USER declined a chat. Once the skip-if-present
|
|
8
|
+
* deadlock was replaced (see the module doc) three more became reachable, and
|
|
9
|
+
* two of them are about the *host* side — so the item also carries `side`.
|
|
10
|
+
* Collapsing them all into `unchecked` would tell a user their own terminal
|
|
11
|
+
* transcript was "not ticked", which is both false and alarming.
|
|
12
|
+
*/
|
|
13
|
+
export type MigrationPreserveReason =
|
|
14
|
+
/** The user did not tick it. Paddock's copy set aside; the user's survives. */
|
|
15
|
+
"unchecked"
|
|
16
|
+
/** Appeared after the plan was built and classified `diverged`, so its own
|
|
17
|
+
* default (unchecked) was applied (§4.6). */
|
|
18
|
+
| "unplanned-diverged"
|
|
19
|
+
/** Byte-identical on both sides, so no choice was ever offered. Paddock's
|
|
20
|
+
* redundant copy is set aside rather than deleted. */
|
|
21
|
+
| "identical"
|
|
22
|
+
/** Fast-forward with the HOST side ahead: the user's copy is the descendant
|
|
23
|
+
* and survives, Paddock's ancestor is set aside. Lossless either way. */
|
|
24
|
+
| "already-ahead"
|
|
25
|
+
/** The user's copy was superseded by Paddock's and moved out of `~/.claude`
|
|
26
|
+
* BEFORE the replacement landed. The recovery path for a decision the user
|
|
27
|
+
* regrets. */
|
|
28
|
+
| "superseded";
|
|
29
|
+
export interface MigrationPreservedChat {
|
|
30
|
+
/**
|
|
31
|
+
* The chat id — or, for an agent-memory file set aside by the `memory/`
|
|
32
|
+
* merge, its path relative to the store (`memory/MEMORY.md`).
|
|
33
|
+
*
|
|
34
|
+
* A memory file is not a chat, and it is in this array anyway: this is the
|
|
35
|
+
* one place the completion screen renders as "here is everything that was
|
|
36
|
+
* set aside and where it went", and a recovery path with a hole in it is
|
|
37
|
+
* worse than a slightly loose field name.
|
|
38
|
+
*/
|
|
39
|
+
sessionId: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
/** Which store the preserved copy came out of. */
|
|
42
|
+
side: "own" | "host";
|
|
43
|
+
/** Absolute path of the preserved transcript, as actually written. */
|
|
44
|
+
path: string;
|
|
45
|
+
reason: MigrationPreserveReason;
|
|
46
|
+
}
|
|
47
|
+
export type MigrationFailureReason = "destination-exists" | "unreadable" | "move-failed" | "preserve-failed" | "unknown";
|
|
48
|
+
export interface MigrationFailure {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
slug: string;
|
|
51
|
+
reason: MigrationFailureReason;
|
|
52
|
+
message?: string;
|
|
53
|
+
/** The path the failure is about — disambiguates a project from its sweeper,
|
|
54
|
+
* which share a slug. */
|
|
55
|
+
path?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface MigrationUnplannedChat {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
slug: string;
|
|
60
|
+
state: MigrationState;
|
|
61
|
+
action: "migrated" | "preserved";
|
|
62
|
+
}
|
|
63
|
+
export type MigrationProjectOutcome = "migrated" | "nothing-to-do" | "skipped-busy" | "failed";
|
|
64
|
+
export interface MigrationProjectResult {
|
|
65
|
+
slug: string;
|
|
66
|
+
outcome: MigrationProjectOutcome;
|
|
67
|
+
migrated: number;
|
|
68
|
+
preserved: number;
|
|
69
|
+
/**
|
|
70
|
+
* Whether `<project.dir>/.chats/` is empty afterwards. FALSE IS A FAILURE:
|
|
71
|
+
* the redirect symlink will not be planted on restart and the project will be
|
|
72
|
+
* half-blind (#708). The config is not written while any project reports it.
|
|
73
|
+
*/
|
|
74
|
+
chatsDirEmpty: boolean;
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface MigrationExecuteResult {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
alreadyMigrated: boolean;
|
|
80
|
+
dryRun: boolean;
|
|
81
|
+
projects: MigrationProjectResult[];
|
|
82
|
+
migrated: string[];
|
|
83
|
+
preserved: MigrationPreservedChat[];
|
|
84
|
+
unplanned: MigrationUnplannedChat[];
|
|
85
|
+
/** Ids in the request that are not in any `.chats/`. Ignored, and named
|
|
86
|
+
* rather than silently dropped — the design says they are "reported under
|
|
87
|
+
* unknown" and then gives the 200 body nowhere to report them. */
|
|
88
|
+
ignoredSessionIds: string[];
|
|
89
|
+
failed: MigrationFailure[];
|
|
90
|
+
sweepers: {
|
|
91
|
+
stores: number;
|
|
92
|
+
chats: number;
|
|
93
|
+
};
|
|
94
|
+
/** Carried through from the scan. `memory-collision` in particular is
|
|
95
|
+
* REQUIRED by §10.2 and had nowhere to live in the design's 200 body. */
|
|
96
|
+
warnings: MigrationWarning[];
|
|
97
|
+
configWritten: boolean;
|
|
98
|
+
configPath: string;
|
|
99
|
+
configVersion?: string;
|
|
100
|
+
restartRequired: boolean;
|
|
101
|
+
}
|
|
102
|
+
export interface MigrationExecuteInput extends MigrationInput {
|
|
103
|
+
/** The chat ids the user TICKED. Everything else is preserved, not migrated. */
|
|
104
|
+
sessionIds: string[];
|
|
105
|
+
/**
|
|
106
|
+
* Every id the plan the user was looking at contained.
|
|
107
|
+
*
|
|
108
|
+
* Without it §4.6 is unimplementable: "a chat created between preview and
|
|
109
|
+
* submit" and "a chat the user deliberately unticked" are both "present on
|
|
110
|
+
* disk and absent from `sessionIds`", and nothing in the design's request
|
|
111
|
+
* body tells them apart. Optional — when omitted, every untricked chat is
|
|
112
|
+
* treated as a deliberate choice and `unplanned` comes back empty.
|
|
113
|
+
*/
|
|
114
|
+
plannedSessionIds?: string[];
|
|
115
|
+
/** Report what WOULD happen; move nothing, write nothing. */
|
|
116
|
+
dryRun?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* The transcripts mode a RESTART would resolve from the config FILE — NOT
|
|
119
|
+
* `mode`, which is what this process froze at boot. They differ for the whole
|
|
120
|
+
* window between a successful migration and the restart it asks for, which is
|
|
121
|
+
* exactly the window a second POST arrives in.
|
|
122
|
+
*/
|
|
123
|
+
pendingMode: TranscriptsMode;
|
|
124
|
+
/**
|
|
125
|
+
* §4.5's re-check: session ids with a live turn in `slug`, right now.
|
|
126
|
+
*
|
|
127
|
+
* `quiesceSession` stops what is running; it does not take a lock, so a fresh
|
|
128
|
+
* turn can start between the quiesce and this project's own moves. Called
|
|
129
|
+
* immediately before them, which narrows the window to microseconds without
|
|
130
|
+
* inventing a fleet-wide turn lock (§10.3 ruled that out of scope for v1).
|
|
131
|
+
*/
|
|
132
|
+
busySessions?: (slug: string) => string[];
|
|
133
|
+
/** Write `claude.transcripts: host`; returns the new config fingerprint.
|
|
134
|
+
* Called ONCE, LAST, and only when every project reached the postcondition. */
|
|
135
|
+
commitConfig: () => Promise<string | null>;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Steps 4–6: re-enumerate from disk, move, and — only if every project reached
|
|
139
|
+
* the postcondition — write the config.
|
|
140
|
+
*
|
|
141
|
+
* Steps 1–3 (single-flight, `expectedVersion`, quiesce) are the caller's; see
|
|
142
|
+
* the module doc for why.
|
|
143
|
+
*/
|
|
144
|
+
export declare function executeMigration(input: MigrationExecuteInput): Promise<MigrationExecuteResult>;
|