@edspencer/paddock 0.71.1 → 0.72.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.
Files changed (113) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +67 -8
  4. package/packages/server/dist/cli/args.js +213 -33
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/cli/service/backend.d.ts +33 -0
  9. package/packages/server/dist/cli/service/index.d.ts +3 -3
  10. package/packages/server/dist/cli/service/index.js +124 -2
  11. package/packages/server/dist/cli/service/launchd.d.ts +27 -4
  12. package/packages/server/dist/cli/service/launchd.js +88 -14
  13. package/packages/server/dist/cli/service/systemd.d.ts +21 -2
  14. package/packages/server/dist/cli/service/systemd.js +58 -5
  15. package/packages/server/dist/config.d.ts +36 -1
  16. package/packages/server/dist/config.js +59 -44
  17. package/packages/server/dist/herdctl.d.ts +45 -0
  18. package/packages/server/dist/herdctl.js +58 -4
  19. package/packages/server/dist/instance-config.d.ts +149 -21
  20. package/packages/server/dist/instance-config.js +228 -5
  21. package/packages/server/dist/last-activity.d.ts +21 -0
  22. package/packages/server/dist/last-activity.js +7 -2
  23. package/packages/server/dist/profiles.d.ts +154 -0
  24. package/packages/server/dist/profiles.js +214 -0
  25. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  26. package/packages/server/dist/routes/transcripts.js +676 -0
  27. package/packages/server/dist/routes.d.ts +3 -0
  28. package/packages/server/dist/routes.js +2 -0
  29. package/packages/server/dist/transcript-move.d.ts +62 -0
  30. package/packages/server/dist/transcript-move.js +179 -0
  31. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  32. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  33. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  34. package/packages/server/dist/transcripts-migration.js +917 -0
  35. package/packages/server/dist/transcripts.d.ts +46 -2
  36. package/packages/server/dist/transcripts.js +126 -3
  37. package/packages/server/package.json +1 -1
  38. package/packages/web/dist/assets/DiscoverPage-DXI9FjmJ.js +2 -0
  39. package/packages/web/dist/assets/DiscoverView-D9oqVkT4.js +2 -0
  40. package/packages/web/dist/assets/InstanceConfigPage-DyH1j5oR.js +2 -0
  41. package/packages/web/dist/assets/{MarkdownRenderer-DdoNl0Ao.js → MarkdownRenderer-D95ofnQ8.js} +1 -1
  42. package/packages/web/dist/assets/ProjectMenu-BO70tGwy.js +2 -0
  43. package/packages/web/dist/assets/{ProjectRedirect-QuGeN-qx.js → ProjectRedirect-BxX_aADU.js} +1 -1
  44. package/packages/web/dist/assets/ProjectView-BbCyKoh5.js +13 -0
  45. package/packages/web/dist/assets/{ProjectsGrid-CUFNVTif.js → ProjectsGrid-BafOHL43.js} +1 -1
  46. package/packages/web/dist/assets/RootHome-BGkMypUJ.js +2 -0
  47. package/packages/web/dist/assets/Surfaces-B8SyV4r3.js +2 -0
  48. package/packages/web/dist/assets/{arc-C0DRwCCT.js → arc-DGyr--oL.js} +1 -1
  49. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BNczNNmx.js +37 -0
  50. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DKmCbgWk.js → blockDiagram-GPEHLZMM-DiTvGSrV.js} +1 -1
  51. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CJ8LiLsj.js → c4Diagram-AAUBKEIU-CKZ_jy81.js} +1 -1
  52. package/packages/web/dist/assets/channel-CjwDi1A9.js +2 -0
  53. package/packages/web/dist/assets/{chunk-2J33WTMH-C3P-O4C3.js → chunk-2J33WTMH-5o96lthu.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-4BX2VUAB-Cs9u7x61.js → chunk-4BX2VUAB-38_pZfd8.js} +1 -1
  55. package/packages/web/dist/assets/{chunk-55IACEB6-C1st284U.js → chunk-55IACEB6-Db73LvlE.js} +1 -1
  56. package/packages/web/dist/assets/{chunk-727SXJPM-BjCAWo7B.js → chunk-727SXJPM-C-Q1-wUu.js} +1 -1
  57. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BP6S9w2Q.js → chunk-AQP2D5EJ-ClJigITk.js} +1 -1
  58. package/packages/web/dist/assets/{chunk-FMBD7UC4-C3ySTaPs.js → chunk-FMBD7UC4-BVzSX2hw.js} +1 -1
  59. package/packages/web/dist/assets/{chunk-ND2GUHAM-eNPD4vzc.js → chunk-ND2GUHAM-DJlL-ELa.js} +1 -1
  60. package/packages/web/dist/assets/{chunk-QZHKN3VN-BbBpgHkO.js → chunk-QZHKN3VN-BC0QEk6w.js} +1 -1
  61. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CxiNtY3i.js +2 -0
  62. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CxiNtY3i.js +2 -0
  63. package/packages/web/dist/assets/{core-ZwDDec0y.js → core-VUdoTe9w.js} +1 -1
  64. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DVqArcBy.js +2 -0
  65. package/packages/web/dist/assets/{dagre-BM42HDAG-DKIEUM4M.js → dagre-BM42HDAG-BgFGyUdv.js} +1 -1
  66. package/packages/web/dist/assets/{diagram-2AECGRRQ-CqlX7j8t.js → diagram-2AECGRRQ-BqdZmJt3.js} +1 -1
  67. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dph5apqo.js → diagram-5GNKFQAL-DeLPu8jT.js} +1 -1
  68. package/packages/web/dist/assets/{diagram-KO2AKTUF-TW6LyHGt.js → diagram-KO2AKTUF-BqTiTGay.js} +1 -1
  69. package/packages/web/dist/assets/{diagram-LMA3HP47-BxxIIv5a.js → diagram-LMA3HP47-CbpAvCgl.js} +1 -1
  70. package/packages/web/dist/assets/{diagram-OG6HWLK6-B8M0DeV7.js → diagram-OG6HWLK6-C4h0V97s.js} +1 -1
  71. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bfpy6UQa.js → erDiagram-TEJ5UH35-Dwz77uRx.js} +1 -1
  72. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DstRzxek.js → flowDiagram-I6XJVG4X-DSikJnZR.js} +1 -1
  73. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-DkAGNZb3.js +293 -0
  74. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CX4IozE-.js → gitGraphDiagram-PVQCEYII-BQtzt9hP.js} +1 -1
  75. package/packages/web/dist/assets/index-BN3rLMpD.js +71 -0
  76. package/packages/web/dist/assets/index-Dfzb1DvL.css +1 -0
  77. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CG2qCk6u.js → infoDiagram-5YYISTIA-YcaBUjeC.js} +1 -1
  78. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CPErvl3a.js → ishikawaDiagram-YF4QCWOH-BfLZB88u.js} +1 -1
  79. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bm6krUsf.js → journeyDiagram-JHISSGLW-DwaFhd6C.js} +1 -1
  80. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B4CC8CAx.js → kanban-definition-UN3LZRKU-e9P3WxsD.js} +1 -1
  81. package/packages/web/dist/assets/{linear-CJ1DbrOX.js → linear-CsXS5LTz.js} +1 -1
  82. package/packages/web/dist/assets/{mermaid.core-Dk2kkhf9.js → mermaid.core-DsA72Qyi.js} +4 -4
  83. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CThvf_YD.js → mindmap-definition-RKZ34NQL-DyLOITDI.js} +1 -1
  84. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DvkHcng6.js → pieDiagram-4H26LBE5-C36aM3D-.js} +1 -1
  85. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CqhrTh9q.js → quadrantDiagram-W4KKPZXB-Cq1zTk6a.js} +1 -1
  86. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZykiLym.js → requirementDiagram-4Y6WPE33-B6MctUk7.js} +1 -1
  87. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D-TzCKMx.js → sankeyDiagram-5OEKKPKP-CaNRUoPR.js} +1 -1
  88. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C5pIItVu.js → sequenceDiagram-3UESZ5HK-DDj_tZiQ.js} +1 -1
  89. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DE6G2JfB.js → stateDiagram-AJRCARHV-DDaMlLyK.js} +1 -1
  90. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bu6Swk2T.js +2 -0
  91. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C1VK2OfU.js → timeline-definition-PNZ67QCA-bMP-7Wam.js} +1 -1
  92. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CtOozEwa.js → vennDiagram-CIIHVFJN-9nzKgc0Y.js} +1 -1
  93. package/packages/web/dist/assets/{wardley-L42UT6IY-DvHGK-lb.js → wardley-L42UT6IY-DIWbAFW5.js} +1 -1
  94. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DxKwZ3-G.js → wardleyDiagram-YWT4CUSO-CVL3skbs.js} +1 -1
  95. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-HM4j3l6y.js → xychartDiagram-2RQKCTM6-DHoVPvll.js} +1 -1
  96. package/packages/web/dist/index.html +2 -2
  97. package/packages/web/dist/sw.js +1 -1
  98. package/packages/web/dist/assets/Chip-Db--PJFH.js +0 -2
  99. package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +0 -2
  100. package/packages/web/dist/assets/DiscoverView-C4hOB3nr.js +0 -2
  101. package/packages/web/dist/assets/InstanceConfigPage-U78M95nW.js +0 -2
  102. package/packages/web/dist/assets/ProjectMenu-BxQu14ze.js +0 -2
  103. package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +0 -13
  104. package/packages/web/dist/assets/RootHome-SIxo-xX9.js +0 -2
  105. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Wwpf4cvW.js +0 -37
  106. package/packages/web/dist/assets/channel-DEmtNKeB.js +0 -2
  107. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +0 -2
  108. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +0 -2
  109. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-DiM4eeF5.js +0 -2
  110. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-Bg0oFbAl.js +0 -293
  111. package/packages/web/dist/assets/index-ASW1Jl0Y.js +0 -71
  112. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  113. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.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>;