@stevecastaneda/paseo-progress 0.1.8 → 0.2.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.
@@ -1,24 +1,14 @@
1
1
  // Everything that reads or writes a worktree's progress files: the event log
2
- // the command appends to, the panel-opened marker, and the .gitignore that keeps
3
- // both out of git. The command and the panel's RPC handlers go through here, so
4
- // they always agree on where the files are and how they're read.
2
+ // the command appends to and the panel-opened marker. Each function takes the
3
+ // folder that holds them, which `findStore` in store.ts picks. The command and
4
+ // the panel's RPC handlers go through here, so they agree on how they're read.
5
5
  import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
- import { basename, dirname, join } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { EMPTY_DASHBOARD, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
8
- import { EVENT_VERSION, eventSchema, PANEL_OPENED_FILE, PROGRESS_FILE, type ProgressEvent } from "../shared/events.ts";
8
+ import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME, type ProgressEvent } from "../shared/events.ts";
9
9
  import { withLock } from "./lock.ts";
10
10
 
11
- const SCRATCH_DIR = dirname(PROGRESS_FILE);
12
- const LOCK_FILE = `${PROGRESS_FILE}.lock`;
13
-
14
- // Names only this plugin's files, so they stay out of git without the user
15
- // editing their own .gitignore. Other files in .scratch/ are left to the repo.
16
- export const SCRATCH_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
17
- ${basename(PROGRESS_FILE)}
18
- ${basename(LOCK_FILE)}/
19
- ${basename(PANEL_OPENED_FILE)}
20
- .gitignore
21
- `;
11
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
22
12
 
23
13
  // An event as a command builds it; `append` stamps the version and time.
24
14
  export type EventDraft = { [K in ProgressEvent["type"]]: Omit<Extract<ProgressEvent, { type: K }>, "v" | "ts"> }[ProgressEvent["type"]];
@@ -33,7 +23,7 @@ export interface ProgressRead {
33
23
  panelOpened: boolean;
34
24
  }
35
25
 
36
- // The parsed file per worktree, kept until its size or change time moves. The
26
+ // The parsed file per folder, kept until its size or change time moves. The
37
27
  // file only grows, and the panel and pills read it every few seconds.
38
28
  const parsedFiles = new Map<string, { size: number; mtimeMs: number; ctimeMs: number; parsed: ReturnType<typeof parseProgress> }>();
39
29
  // Replaced file snapshots and their derived data can be collected together.
@@ -43,9 +33,9 @@ const dashboards = new WeakMap<ReturnType<typeof parseProgress>, { dashboard: Da
43
33
  // reuse the last one until the file changes or a stale or overdue time passes.
44
34
  // Stale and overdue checks use the daemon host's clock, the same clock the
45
35
  // command stamps events with.
46
- export async function readProgress(root: string, now = new Date()): Promise<ProgressRead> {
47
- const parsed = await readParsed(root);
48
- const panelOpened = await exists(join(root, PANEL_OPENED_FILE));
36
+ export async function readProgress(directory: string, now = new Date()): Promise<ProgressRead> {
37
+ const parsed = await readParsed(directory);
38
+ const panelOpened = await exists(join(directory, PANEL_OPENED_NAME));
49
39
  if (parsed === null) return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
50
40
  let cached = dashboards.get(parsed);
51
41
  // A backwards clock jump must also re-evaluate stale/overdue state.
@@ -61,13 +51,15 @@ export async function readProgress(root: string, now = new Date()): Promise<Prog
61
51
  // that run at once take turns, so each sees the others' events and no two hand
62
52
  // out the same id. `write` may throw to append nothing.
63
53
  export async function appendProgress<T>(
64
- root: string,
54
+ directory: string,
65
55
  now: () => Date,
66
56
  write: (state: { dashboard: Dashboard; nextIds: NextIds }) => { event: EventDraft; result: T },
57
+ // Runs before the lock is released, with the whole file as it now is.
58
+ after?: (text: string) => Promise<void>,
67
59
  ): Promise<T> {
68
- await prepareScratch(root);
69
- return withLock(join(root, LOCK_FILE), async () => {
70
- const text = (await readText(root)) ?? "";
60
+ await mkdir(directory, { recursive: true });
61
+ return withLock(join(directory, LOCK_NAME), async () => {
62
+ const text = (await readText(directory)) ?? "";
71
63
  const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
72
64
  const { event, result } = write({ dashboard, nextIds });
73
65
  const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
@@ -76,54 +68,46 @@ export async function appendProgress<T>(
76
68
  }
77
69
  // A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
78
70
  const lead = text && !text.endsWith("\n") ? "\n" : "";
79
- await appendFile(join(root, PROGRESS_FILE), `${lead}${JSON.stringify(checked.data)}\n`);
71
+ const line = `${lead}${JSON.stringify(checked.data)}\n`;
72
+ await appendFile(join(directory, PROGRESS_FILE_NAME), line);
73
+ await after?.(text + line);
80
74
  return result;
81
75
  });
82
76
  }
83
77
 
84
- export async function markPanelOpened(root: string): Promise<void> {
85
- await prepareScratch(root);
86
- await writeFile(join(root, PANEL_OPENED_FILE), "");
87
- }
88
-
89
- // Creates .scratch/ in the worktree and, the first time, its .gitignore. An
90
- // existing .gitignore is the user's, so it is never touched.
91
- async function prepareScratch(root: string): Promise<void> {
92
- const directory = join(root, SCRATCH_DIR);
78
+ export async function markPanelOpened(directory: string): Promise<void> {
93
79
  await mkdir(directory, { recursive: true });
94
- await writeFile(join(directory, ".gitignore"), SCRATCH_GITIGNORE, { flag: "wx" }).catch((error: NodeJS.ErrnoException) => {
95
- if (error.code !== "EEXIST") throw error;
96
- });
80
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
97
81
  }
98
82
 
99
- async function readText(root: string): Promise<string | null> {
83
+ async function readText(directory: string): Promise<string | null> {
100
84
  try {
101
- return await readFile(join(root, PROGRESS_FILE), "utf8");
85
+ return await readFile(join(directory, PROGRESS_FILE_NAME), "utf8");
102
86
  } catch (error) {
103
87
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
104
88
  throw error;
105
89
  }
106
90
  }
107
91
 
108
- async function readParsed(root: string): Promise<ReturnType<typeof parseProgress> | null> {
109
- const info = await stat(join(root, PROGRESS_FILE)).catch((error: NodeJS.ErrnoException) => {
92
+ async function readParsed(directory: string): Promise<ReturnType<typeof parseProgress> | null> {
93
+ const info = await stat(join(directory, PROGRESS_FILE_NAME)).catch((error: NodeJS.ErrnoException) => {
110
94
  if (error.code === "ENOENT") return null;
111
95
  throw error;
112
96
  });
113
97
  if (!info) {
114
- parsedFiles.delete(root);
98
+ parsedFiles.delete(directory);
115
99
  return null;
116
100
  }
117
- const cached = parsedFiles.get(root);
101
+ const cached = parsedFiles.get(directory);
118
102
  if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs) return cached.parsed;
119
103
  // Stat before reading: if the file grows in between, the next stat won't match and it's read again.
120
- const text = await readText(root);
104
+ const text = await readText(directory);
121
105
  if (text === null) {
122
- parsedFiles.delete(root);
106
+ parsedFiles.delete(directory);
123
107
  return null;
124
108
  }
125
109
  const parsed = parseProgress(text);
126
- parsedFiles.set(root, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
110
+ parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
127
111
  return parsed;
128
112
  }
129
113
 
@@ -0,0 +1,205 @@
1
+ // Where a worktree's progress files live, and the run history a repo can opt
2
+ // in to saving. The working files never touch the repo: they live outside it,
3
+ // one folder per worktree, so git never sees them and nothing needs hiding.
4
+ // Only runs the user asks to save are written into the repo, one file per run.
5
+ import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { realpathSync } from "node:fs";
8
+ import { appendFile, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { basename, join, normalize, resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.ts";
13
+ import { withLock } from "./lock.ts";
14
+ import { outside } from "./paths.ts";
15
+
16
+ const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
17
+ const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
18
+ // Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
19
+ const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
20
+ ${PROGRESS_FILE_NAME}
21
+ ${LOCK_NAME}/
22
+ ${PANEL_OPENED_NAME}
23
+ .gitignore
24
+ `;
25
+
26
+ // `directory` holds the working files. `shown` names it for people.
27
+ export type Store = { directory: string; shown: string };
28
+
29
+ export async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string }> {
30
+ try {
31
+ const { stdout } = await promisify(execFile)("git", args, { cwd });
32
+ return { code: 0, stdout };
33
+ } catch (error) {
34
+ const failed = error as { code?: number | string; stdout?: string };
35
+ return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
36
+ }
37
+ }
38
+
39
+ async function exists(path: string): Promise<boolean> {
40
+ return Boolean(await stat(path).catch(() => null));
41
+ }
42
+
43
+ // One folder per worktree on this computer, named so a person can tell which
44
+ // worktree it belongs to.
45
+ export function outsideDirectory(root: string, home = homedir()): string {
46
+ let real = root;
47
+ try {
48
+ real = realpathSync(root);
49
+ } catch {}
50
+ const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
51
+ return join(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash}`);
52
+ }
53
+
54
+ // Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
55
+ const trackedLegacy = new Map<string, { mtimeMs: number; tracked: boolean }>();
56
+
57
+ async function legacyTracked(root: string, mtimeMs: number): Promise<boolean> {
58
+ const cached = trackedLegacy.get(root);
59
+ if (cached && cached.mtimeMs === mtimeMs) return cached.tracked;
60
+ const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
61
+ trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
62
+ return code === 0;
63
+ }
64
+
65
+ // The worktree's working files. A run 0.1.x left in .scratch moves outside the
66
+ // first time anything asks, unless git tracks it: then it stays and is used in
67
+ // place, so nothing changes for a repo that commits it.
68
+ export async function findStore(root: string, home = homedir()): Promise<Store> {
69
+ const directory = outsideDirectory(root, home);
70
+ const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
71
+ const legacy = join(root, LEGACY_FOLDER);
72
+ const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
73
+ if (!info) return store;
74
+ if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
75
+ await moveLegacy(legacy, directory);
76
+ return store;
77
+ }
78
+
79
+ // Appends the old file's lines to the new one rather than replacing it, so a
80
+ // command that wrote to the old place mid-move loses nothing: the next move
81
+ // picks those lines up. Then tidies what 0.1.x left behind.
82
+ async function moveLegacy(legacy: string, directory: string): Promise<void> {
83
+ await mkdir(directory, { recursive: true });
84
+ await withLock(join(legacy, LOCK_NAME), async () => {
85
+ const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
86
+ if (text === null) return;
87
+ await withLock(join(directory, LOCK_NAME), async () => {
88
+ const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
89
+ const lead = current && !current.endsWith("\n") ? "\n" : "";
90
+ await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
91
+ });
92
+ await rm(join(legacy, PROGRESS_FILE_NAME));
93
+ if (await exists(join(legacy, PANEL_OPENED_NAME))) {
94
+ await writeFile(join(directory, PANEL_OPENED_NAME), "");
95
+ await rm(join(legacy, PANEL_OPENED_NAME));
96
+ }
97
+ });
98
+ const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
99
+ if (ignore === LEGACY_GITIGNORE) await rm(join(legacy, ".gitignore"));
100
+ // Only if nothing else is in there; the user's own files keep the folder.
101
+ await rmdir(legacy).catch(() => {});
102
+ }
103
+
104
+ // The repo's saved-history folder, kept in its local git config, which every
105
+ // worktree of the repo shares and nothing commits. Polls cost one stat; git
106
+ // runs again only when the config file changes.
107
+ const configFiles = new Map<string, string | null>();
108
+ const savedFolders = new Map<string, { mtimeMs: number; folder: string | null }>();
109
+
110
+ async function configFile(root: string): Promise<string | null> {
111
+ if (!configFiles.has(root)) {
112
+ const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
113
+ configFiles.set(root, code === 0 ? join(resolve(root, stdout.trim()), "config") : null);
114
+ }
115
+ return configFiles.get(root)!;
116
+ }
117
+
118
+ // Whether the worktree is in a git repo, which saving run history needs.
119
+ export async function inGitRepo(root: string): Promise<boolean> {
120
+ return (await configFile(root)) !== null;
121
+ }
122
+
123
+ // The folder runs are saved to, relative to the worktree root, or null.
124
+ export async function historyFolder(root: string): Promise<string | null> {
125
+ const file = await configFile(root);
126
+ if (file === null) return null;
127
+ const mtimeMs = (await stat(file).catch(() => null))?.mtimeMs ?? 0;
128
+ const cached = savedFolders.get(file);
129
+ if (cached && cached.mtimeMs === mtimeMs) return cached.folder;
130
+ const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
131
+ const folder = stdout.trim() || null;
132
+ savedFolders.set(file, { mtimeMs, folder });
133
+ return folder;
134
+ }
135
+
136
+ // A folder the user picked, as a clean path inside the worktree, or why not.
137
+ export function checkFolder(raw: string): { folder: string } | { error: string } {
138
+ const trimmed = raw.trim().replace(/\/+$/, "");
139
+ if (!trimmed) return { error: "Choose a folder" };
140
+ const folder = normalize(trimmed);
141
+ if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
142
+ if (folder === ".git" || folder.startsWith(".git/")) return { error: "Choose a folder outside .git" };
143
+ return { folder };
144
+ }
145
+
146
+ // Starts or stops saving runs for the whole repo. Files already saved stay.
147
+ export async function setHistoryFolder(root: string, raw: string | null): Promise<string | null> {
148
+ if (raw === null) {
149
+ const { code } = await git(root, ["config", "--local", "--unset-all", SAVE_RUNS_KEY]);
150
+ // 5: it wasn't set, which is what was asked for.
151
+ if (code !== 0 && code !== 5) throw new Error("Could not change this repo's git settings.");
152
+ return null;
153
+ }
154
+ const checked = checkFolder(raw);
155
+ if ("error" in checked) throw new Error(checked.error);
156
+ const { code } = await git(root, ["config", "--local", SAVE_RUNS_KEY, checked.folder]);
157
+ if (code !== 0) throw new Error("Could not save this in the repo's git settings. Is it a git repo?");
158
+ return checked.folder;
159
+ }
160
+
161
+ // "20261001-064512-warn-when-scratch.jsonl": when the run started, then its
162
+ // title as it was at the start, so the name never changes and two runs never
163
+ // share one.
164
+ export function runFileName(startedAt: string, title: string): string {
165
+ const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
166
+ const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
167
+ return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
168
+ }
169
+
170
+ // After each change: when the repo saves runs, rewrite the current run's file
171
+ // with every event from its start. Runs before the first start aren't saved.
172
+ export async function saveRunCopy(root: string, text: string): Promise<void> {
173
+ const folder = await historyFolder(root);
174
+ if (!folder) return;
175
+ const lines = text.split("\n").filter((line) => line.trim());
176
+ let start = -1;
177
+ let event: { ts?: string; title?: string } | null = null;
178
+ for (let index = lines.length - 1; index >= 0; index--) {
179
+ try {
180
+ const parsed = JSON.parse(lines[index]);
181
+ if (parsed?.type === "run.start") {
182
+ start = index;
183
+ event = parsed;
184
+ break;
185
+ }
186
+ } catch {}
187
+ }
188
+ if (start < 0 || !event?.ts || !event.title) return;
189
+ const directory = join(root, folder);
190
+ await mkdir(directory, { recursive: true });
191
+ await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
192
+ }
193
+
194
+ // The folders directly inside one folder of the worktree ("" is the top), for
195
+ // the folder picker. .git is left out; so are links, which could leave the repo.
196
+ export async function listFolders(root: string, folder: string): Promise<{ folder: string; folders: string[] }> {
197
+ const checked = folder === "" ? { folder: "" } : checkFolder(folder);
198
+ if ("error" in checked) throw new Error(checked.error);
199
+ const entries = await readdir(join(root, checked.folder), { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
200
+ if (error.code === "ENOENT") return [];
201
+ throw error;
202
+ });
203
+ const folders = entries.filter((entry) => entry.isDirectory() && !(checked.folder === "" && entry.name === ".git")).map((entry) => entry.name);
204
+ return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
205
+ }
package/shared/events.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { z } from "zod";
2
2
 
3
- // One line of `.scratch/progress.jsonl`. The command-line tool writes these;
4
- // the dashboard reducer reads them. `v` lets later versions change a shape
5
- // without misreading old files.
6
- export const PROGRESS_FILE = ".scratch/progress.jsonl";
3
+ // One line of `progress.jsonl`. The command-line tool writes these; the
4
+ // dashboard reducer reads them. `v` lets later versions change a shape without
5
+ // misreading old files. Which folder holds it is set up per repo.
6
+ export const PROGRESS_FILE_NAME = "progress.jsonl";
7
7
  // Present once someone has opened the Progress panel for this worktree.
8
- export const PANEL_OPENED_FILE = ".scratch/progress-panel-opened";
8
+ export const PANEL_OPENED_NAME = "progress-panel-opened";
9
+ // Where 0.1.x kept each worktree's files. store.ts moves them out.
10
+ export const LEGACY_FOLDER = ".scratch";
9
11
  export const EVENT_VERSION = 1;
10
12
 
11
13
  export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;
package/shared/rpc.ts CHANGED
@@ -5,8 +5,13 @@ import type { Dashboard } from "./dashboard.ts";
5
5
  // `panelOpened`: the Progress panel has been opened for this worktree before.
6
6
  // `root`: the worktree root the file was read from, which stored paths are
7
7
  // relative to. It can sit above the workspace directory.
8
+ // `file`: where the progress file is, for people. `savedTo`: the repo folder
9
+ // each run is saved to, or null when history stays on this computer.
10
+ // `inRepo`: saving history needs a git repo to keep the choice in.
8
11
  // `version` changes whenever anything else in the result does.
9
- export type DashboardResult = { configured: boolean; dashboard: Dashboard; panelOpened: boolean; root: string; version: string };
12
+ export type DashboardResult = {
13
+ configured: boolean; dashboard: Dashboard; panelOpened: boolean; file: string; savedTo: string | null; inRepo: boolean; root: string; version: string;
14
+ };
10
15
 
11
16
  export const getDashboard = defineRpc({
12
17
  name: "progress.get",
@@ -83,6 +88,30 @@ export const installSkill = defineRpc({
83
88
  output: skillStatus,
84
89
  });
85
90
 
91
+ // The folders inside one folder of the worktree ("" is the top), for the
92
+ // folder picker.
93
+ export const listFolders = defineRpc({
94
+ name: "progress.folders.list",
95
+ input: z.object({
96
+ workspaceId: z.string().min(1),
97
+ workspaceDirectory: z.string().min(1),
98
+ folder: z.string(),
99
+ }),
100
+ output: z.object({ folder: z.string(), folders: z.array(z.string()) }),
101
+ });
102
+
103
+ // Starts saving each run to a folder in the repo (relative to the worktree
104
+ // root), for every worktree of the repo, or stops when `folder` is null.
105
+ export const setHistoryFolder = defineRpc({
106
+ name: "progress.history.set",
107
+ input: z.object({
108
+ workspaceId: z.string().min(1),
109
+ workspaceDirectory: z.string().min(1),
110
+ folder: z.string().nullable(),
111
+ }),
112
+ output: z.object({ savedTo: z.string().nullable() }),
113
+ });
114
+
86
115
  // Opens a recorded local deliverable on the daemon host with its default app.
87
116
  export const openDeliverable = defineRpc({
88
117
  name: "progress.deliverable.open",