@stevecastaneda/paseo-progress 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/client/history.tsx +157 -0
- package/client/panel.tsx +13 -10
- package/dist/server/cli.js +8 -5
- package/dist/server/progress-file.js +30 -45
- package/dist/server/store.js +205 -0
- package/dist/shared/events.js +7 -5
- package/index.server.ts +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/cli.ts +8 -5
- package/server/dashboard.ts +30 -7
- package/server/open.ts +2 -1
- package/server/progress-file.ts +30 -46
- package/server/store.ts +205 -0
- package/shared/events.ts +7 -5
- package/shared/rpc.ts +30 -1
|
@@ -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.js";
|
|
13
|
+
import { withLock } from "./lock.js";
|
|
14
|
+
import { outside } from "./paths.js";
|
|
15
|
+
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
16
|
+
const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
|
|
17
|
+
// Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
|
|
18
|
+
const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
|
|
19
|
+
${PROGRESS_FILE_NAME}
|
|
20
|
+
${LOCK_NAME}/
|
|
21
|
+
${PANEL_OPENED_NAME}
|
|
22
|
+
.gitignore
|
|
23
|
+
`;
|
|
24
|
+
export async function git(cwd, args) {
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
27
|
+
return { code: 0, stdout };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const failed = error;
|
|
31
|
+
return { code: typeof failed.code === "number" ? failed.code : -1, stdout: failed.stdout ?? "" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function exists(path) {
|
|
35
|
+
return Boolean(await stat(path).catch(() => null));
|
|
36
|
+
}
|
|
37
|
+
// One folder per worktree on this computer, named so a person can tell which
|
|
38
|
+
// worktree it belongs to.
|
|
39
|
+
export function outsideDirectory(root, home = homedir()) {
|
|
40
|
+
let real = root;
|
|
41
|
+
try {
|
|
42
|
+
real = realpathSync(root);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
|
|
46
|
+
return join(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash}`);
|
|
47
|
+
}
|
|
48
|
+
// Whether git tracks a 0.1.x progress file, per worktree, until the file changes.
|
|
49
|
+
const trackedLegacy = new Map();
|
|
50
|
+
async function legacyTracked(root, mtimeMs) {
|
|
51
|
+
const cached = trackedLegacy.get(root);
|
|
52
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
53
|
+
return cached.tracked;
|
|
54
|
+
const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
|
|
55
|
+
trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
|
|
56
|
+
return code === 0;
|
|
57
|
+
}
|
|
58
|
+
// The worktree's working files. A run 0.1.x left in .scratch moves outside the
|
|
59
|
+
// first time anything asks, unless git tracks it: then it stays and is used in
|
|
60
|
+
// place, so nothing changes for a repo that commits it.
|
|
61
|
+
export async function findStore(root, home = homedir()) {
|
|
62
|
+
const directory = outsideDirectory(root, home);
|
|
63
|
+
const store = { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
|
|
64
|
+
const legacy = join(root, LEGACY_FOLDER);
|
|
65
|
+
const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
|
|
66
|
+
if (!info)
|
|
67
|
+
return store;
|
|
68
|
+
if (await legacyTracked(root, info.mtimeMs))
|
|
69
|
+
return { directory: legacy, shown: LEGACY_FOLDER };
|
|
70
|
+
await moveLegacy(legacy, directory);
|
|
71
|
+
return store;
|
|
72
|
+
}
|
|
73
|
+
// Appends the old file's lines to the new one rather than replacing it, so a
|
|
74
|
+
// command that wrote to the old place mid-move loses nothing: the next move
|
|
75
|
+
// picks those lines up. Then tidies what 0.1.x left behind.
|
|
76
|
+
async function moveLegacy(legacy, directory) {
|
|
77
|
+
await mkdir(directory, { recursive: true });
|
|
78
|
+
await withLock(join(legacy, LOCK_NAME), async () => {
|
|
79
|
+
const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
|
|
80
|
+
if (text === null)
|
|
81
|
+
return;
|
|
82
|
+
await withLock(join(directory, LOCK_NAME), async () => {
|
|
83
|
+
const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
|
|
84
|
+
const lead = current && !current.endsWith("\n") ? "\n" : "";
|
|
85
|
+
await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
|
|
86
|
+
});
|
|
87
|
+
await rm(join(legacy, PROGRESS_FILE_NAME));
|
|
88
|
+
if (await exists(join(legacy, PANEL_OPENED_NAME))) {
|
|
89
|
+
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
90
|
+
await rm(join(legacy, PANEL_OPENED_NAME));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
|
|
94
|
+
if (ignore === LEGACY_GITIGNORE)
|
|
95
|
+
await rm(join(legacy, ".gitignore"));
|
|
96
|
+
// Only if nothing else is in there; the user's own files keep the folder.
|
|
97
|
+
await rmdir(legacy).catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
// The repo's saved-history folder, kept in its local git config, which every
|
|
100
|
+
// worktree of the repo shares and nothing commits. Polls cost one stat; git
|
|
101
|
+
// runs again only when the config file changes.
|
|
102
|
+
const configFiles = new Map();
|
|
103
|
+
const savedFolders = new Map();
|
|
104
|
+
async function configFile(root) {
|
|
105
|
+
if (!configFiles.has(root)) {
|
|
106
|
+
const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
107
|
+
configFiles.set(root, code === 0 ? join(resolve(root, stdout.trim()), "config") : null);
|
|
108
|
+
}
|
|
109
|
+
return configFiles.get(root);
|
|
110
|
+
}
|
|
111
|
+
// Whether the worktree is in a git repo, which saving run history needs.
|
|
112
|
+
export async function inGitRepo(root) {
|
|
113
|
+
return (await configFile(root)) !== null;
|
|
114
|
+
}
|
|
115
|
+
// The folder runs are saved to, relative to the worktree root, or null.
|
|
116
|
+
export async function historyFolder(root) {
|
|
117
|
+
const file = await configFile(root);
|
|
118
|
+
if (file === null)
|
|
119
|
+
return null;
|
|
120
|
+
const mtimeMs = (await stat(file).catch(() => null))?.mtimeMs ?? 0;
|
|
121
|
+
const cached = savedFolders.get(file);
|
|
122
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
123
|
+
return cached.folder;
|
|
124
|
+
const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
|
|
125
|
+
const folder = stdout.trim() || null;
|
|
126
|
+
savedFolders.set(file, { mtimeMs, folder });
|
|
127
|
+
return folder;
|
|
128
|
+
}
|
|
129
|
+
// A folder the user picked, as a clean path inside the worktree, or why not.
|
|
130
|
+
export function checkFolder(raw) {
|
|
131
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
132
|
+
if (!trimmed)
|
|
133
|
+
return { error: "Choose a folder" };
|
|
134
|
+
const folder = normalize(trimmed);
|
|
135
|
+
if (outside(folder) || folder === ".")
|
|
136
|
+
return { error: "Choose a folder inside the repo" };
|
|
137
|
+
if (folder === ".git" || folder.startsWith(".git/"))
|
|
138
|
+
return { error: "Choose a folder outside .git" };
|
|
139
|
+
return { folder };
|
|
140
|
+
}
|
|
141
|
+
// Starts or stops saving runs for the whole repo. Files already saved stay.
|
|
142
|
+
export async function setHistoryFolder(root, raw) {
|
|
143
|
+
if (raw === null) {
|
|
144
|
+
const { code } = await git(root, ["config", "--local", "--unset-all", SAVE_RUNS_KEY]);
|
|
145
|
+
// 5: it wasn't set, which is what was asked for.
|
|
146
|
+
if (code !== 0 && code !== 5)
|
|
147
|
+
throw new Error("Could not change this repo's git settings.");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const checked = checkFolder(raw);
|
|
151
|
+
if ("error" in checked)
|
|
152
|
+
throw new Error(checked.error);
|
|
153
|
+
const { code } = await git(root, ["config", "--local", SAVE_RUNS_KEY, checked.folder]);
|
|
154
|
+
if (code !== 0)
|
|
155
|
+
throw new Error("Could not save this in the repo's git settings. Is it a git repo?");
|
|
156
|
+
return checked.folder;
|
|
157
|
+
}
|
|
158
|
+
// "20261001-064512-warn-when-scratch.jsonl": when the run started, then its
|
|
159
|
+
// title as it was at the start, so the name never changes and two runs never
|
|
160
|
+
// share one.
|
|
161
|
+
export function runFileName(startedAt, title) {
|
|
162
|
+
const stamp = startedAt.replace(/\.\d+Z$/, "").replace(/[-:]/g, "").replace("T", "-");
|
|
163
|
+
const slug = title.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
|
|
164
|
+
return `${stamp}${slug ? `-${slug}` : ""}.jsonl`;
|
|
165
|
+
}
|
|
166
|
+
// After each change: when the repo saves runs, rewrite the current run's file
|
|
167
|
+
// with every event from its start. Runs before the first start aren't saved.
|
|
168
|
+
export async function saveRunCopy(root, text) {
|
|
169
|
+
const folder = await historyFolder(root);
|
|
170
|
+
if (!folder)
|
|
171
|
+
return;
|
|
172
|
+
const lines = text.split("\n").filter((line) => line.trim());
|
|
173
|
+
let start = -1;
|
|
174
|
+
let event = null;
|
|
175
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(lines[index]);
|
|
178
|
+
if (parsed?.type === "run.start") {
|
|
179
|
+
start = index;
|
|
180
|
+
event = parsed;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
}
|
|
186
|
+
if (start < 0 || !event?.ts || !event.title)
|
|
187
|
+
return;
|
|
188
|
+
const directory = join(root, folder);
|
|
189
|
+
await mkdir(directory, { recursive: true });
|
|
190
|
+
await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
|
|
191
|
+
}
|
|
192
|
+
// The folders directly inside one folder of the worktree ("" is the top), for
|
|
193
|
+
// the folder picker. .git is left out; so are links, which could leave the repo.
|
|
194
|
+
export async function listFolders(root, folder) {
|
|
195
|
+
const checked = folder === "" ? { folder: "" } : checkFolder(folder);
|
|
196
|
+
if ("error" in checked)
|
|
197
|
+
throw new Error(checked.error);
|
|
198
|
+
const entries = await readdir(join(root, checked.folder), { withFileTypes: true }).catch((error) => {
|
|
199
|
+
if (error.code === "ENOENT")
|
|
200
|
+
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/dist/shared/events.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
// One line of
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
export const
|
|
2
|
+
// One line of `progress.jsonl`. The command-line tool writes these; the
|
|
3
|
+
// dashboard reducer reads them. `v` lets later versions change a shape without
|
|
4
|
+
// misreading old files. Which folder holds it is set up per repo.
|
|
5
|
+
export const PROGRESS_FILE_NAME = "progress.jsonl";
|
|
6
6
|
// Present once someone has opened the Progress panel for this worktree.
|
|
7
|
-
export const
|
|
7
|
+
export const PANEL_OPENED_NAME = "progress-panel-opened";
|
|
8
|
+
// Where 0.1.x kept each worktree's files. store.ts moves them out.
|
|
9
|
+
export const LEGACY_FOLDER = ".scratch";
|
|
8
10
|
export const EVENT_VERSION = 1;
|
|
9
11
|
export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
|
|
10
12
|
export const ticketStatusSchema = z.enum(TICKET_STATUSES);
|
package/index.server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
-
import { handleGetAttention, handleGetDashboard, handleMarkPanelOpened } from "./server/dashboard";
|
|
2
|
+
import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
|
|
3
3
|
import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
4
4
|
import { handleOpenDeliverable, handlePreviewDeliverable } from "./server/open";
|
|
5
5
|
import { handleInstallSkill, handleSkillStatus } from "./server/skill";
|
|
6
|
-
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, markPanelOpened, openDeliverable, previewDeliverable } from "./shared/rpc";
|
|
6
|
+
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, setHistoryFolder } from "./shared/rpc";
|
|
7
7
|
|
|
8
8
|
export default function contribute(server: PluginServerContext) {
|
|
9
9
|
server.handle(getDashboard, handleGetDashboard);
|
|
@@ -15,5 +15,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
15
15
|
server.handle(previewDeliverable, handlePreviewDeliverable);
|
|
16
16
|
server.handle(getSkillStatus, handleSkillStatus);
|
|
17
17
|
server.handle(installSkill, handleInstallSkill);
|
|
18
|
+
server.handle(listFolders, handleListFolders);
|
|
19
|
+
server.handle(setHistoryFolder, handleSetHistoryFolder);
|
|
18
20
|
return () => {};
|
|
19
21
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@stevecastaneda/paseo-progress",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@getpaseo/client": "^0.9.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
package/server/cli.ts
CHANGED
|
@@ -5,10 +5,11 @@ import { relative, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isSettled, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
8
|
-
import { DELIVERABLE_KINDS,
|
|
8
|
+
import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.ts";
|
|
9
9
|
import { appendProgress, InvalidEvent, readProgress, type EventDraft } from "./progress-file.ts";
|
|
10
10
|
import { findRoot, outside } from "./paths.ts";
|
|
11
11
|
import { installLauncher } from "./launcher.ts";
|
|
12
|
+
import { findStore, saveRunCopy } from "./store.ts";
|
|
12
13
|
import { dashboardText } from "../shared/show.ts";
|
|
13
14
|
import { imageMimeType } from "../shared/attachments.ts";
|
|
14
15
|
import { homedir } from "node:os";
|
|
@@ -438,7 +439,7 @@ function help(): string {
|
|
|
438
439
|
return [
|
|
439
440
|
`Usage: ${USAGE_NAME} <command>`,
|
|
440
441
|
"",
|
|
441
|
-
`Records progress
|
|
442
|
+
`Records progress for this worktree, shown by the Progress panel in Paseo. The file lives outside the repo, so git never sees it.`,
|
|
442
443
|
`Add --help after any command for its options.`,
|
|
443
444
|
"",
|
|
444
445
|
...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
|
|
@@ -464,7 +465,8 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
|
|
|
464
465
|
}
|
|
465
466
|
if (first === "show") {
|
|
466
467
|
const root = findRoot(options.cwd);
|
|
467
|
-
|
|
468
|
+
const store = await findStore(root);
|
|
469
|
+
out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
|
|
468
470
|
return 0;
|
|
469
471
|
}
|
|
470
472
|
const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
|
|
@@ -490,14 +492,15 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
|
|
|
490
492
|
throw new UsageError((error as Error).message);
|
|
491
493
|
}
|
|
492
494
|
const root = findRoot(options.cwd);
|
|
493
|
-
const
|
|
495
|
+
const store = await findStore(root);
|
|
496
|
+
const message = await appendProgress(store.directory, now, (state) => {
|
|
494
497
|
const finished = state.dashboard.run?.finished;
|
|
495
498
|
if (finished && command !== commands.start) {
|
|
496
499
|
throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
|
|
497
500
|
}
|
|
498
501
|
const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
|
|
499
502
|
return { event, result: message };
|
|
500
|
-
});
|
|
503
|
+
}, (text) => saveRunCopy(root, text));
|
|
501
504
|
out(message);
|
|
502
505
|
return 0;
|
|
503
506
|
} catch (error) {
|
package/server/dashboard.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import type { Dashboard } from "../shared/dashboard.ts";
|
|
5
|
+
import { PROGRESS_FILE_NAME } from "../shared/events.ts";
|
|
4
6
|
import type { AttentionResult, DashboardResult } from "../shared/rpc.ts";
|
|
5
7
|
import { markPanelOpened, readProgress } from "./progress-file.ts";
|
|
6
8
|
import { findRoot } from "./paths.ts";
|
|
9
|
+
import { findStore, historyFolder, inGitRepo, listFolders, setHistoryFolder } from "./store.ts";
|
|
7
10
|
|
|
8
|
-
const versions = new WeakMap<Dashboard, {
|
|
11
|
+
const versions = new WeakMap<Dashboard, { key: string; version: string }>();
|
|
9
12
|
|
|
10
13
|
export async function handleMarkPanelOpened(
|
|
11
14
|
input: { workspaceId: string; workspaceDirectory: string },
|
|
12
15
|
_context: PluginHandlerContext,
|
|
13
16
|
) {
|
|
14
|
-
await markPanelOpened(findRoot(input.workspaceDirectory));
|
|
17
|
+
await markPanelOpened((await findStore(findRoot(input.workspaceDirectory))).directory);
|
|
15
18
|
return {};
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,14 +25,19 @@ export async function handleGetDashboard(
|
|
|
22
25
|
_context: PluginHandlerContext,
|
|
23
26
|
): Promise<DashboardResult | { unchanged: true; version: string }> {
|
|
24
27
|
const root = findRoot(input.workspaceDirectory);
|
|
25
|
-
const
|
|
28
|
+
const store = await findStore(root);
|
|
29
|
+
const result = await readProgress(store.directory);
|
|
30
|
+
const file = join(store.shown, PROGRESS_FILE_NAME);
|
|
31
|
+
const savedTo = await historyFolder(root);
|
|
32
|
+
const inRepo = await inGitRepo(root);
|
|
33
|
+
const key = JSON.stringify([result.panelOpened, file, savedTo, inRepo]);
|
|
26
34
|
let cached = versions.get(result.dashboard);
|
|
27
|
-
if (!cached || cached.
|
|
28
|
-
cached = {
|
|
35
|
+
if (!cached || cached.key !== key) {
|
|
36
|
+
cached = { key, version: createHash("sha1").update(JSON.stringify(result) + key).digest("base64url") };
|
|
29
37
|
versions.set(result.dashboard, cached);
|
|
30
38
|
}
|
|
31
39
|
const { version } = cached;
|
|
32
|
-
return input.since === version ? { unchanged: true, version } : { ...result, root, version };
|
|
40
|
+
return input.since === version ? { unchanged: true, version } : { ...result, file, savedTo, inRepo, root, version };
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
// Just what a message-box pill needs, so polling every workspace stays small.
|
|
@@ -37,7 +45,7 @@ export async function handleGetAttention(
|
|
|
37
45
|
input: { workspaceId: string; workspaceDirectory: string },
|
|
38
46
|
_context: PluginHandlerContext,
|
|
39
47
|
): Promise<AttentionResult> {
|
|
40
|
-
const { configured, dashboard, panelOpened } = await readProgress(findRoot(input.workspaceDirectory));
|
|
48
|
+
const { configured, dashboard, panelOpened } = await readProgress((await findStore(findRoot(input.workspaceDirectory))).directory);
|
|
41
49
|
return {
|
|
42
50
|
configured,
|
|
43
51
|
questions: dashboard.questions.open.length,
|
|
@@ -46,3 +54,18 @@ export async function handleGetAttention(
|
|
|
46
54
|
panelOpened,
|
|
47
55
|
};
|
|
48
56
|
}
|
|
57
|
+
|
|
58
|
+
export async function handleListFolders(
|
|
59
|
+
input: { workspaceId: string; workspaceDirectory: string; folder: string },
|
|
60
|
+
_context: PluginHandlerContext,
|
|
61
|
+
) {
|
|
62
|
+
return listFolders(findRoot(input.workspaceDirectory), input.folder);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Runs only when the user picks a folder, or stops saving, in the panel.
|
|
66
|
+
export async function handleSetHistoryFolder(
|
|
67
|
+
input: { workspaceId: string; workspaceDirectory: string; folder: string | null },
|
|
68
|
+
_context: PluginHandlerContext,
|
|
69
|
+
) {
|
|
70
|
+
return { savedTo: await setHistoryFolder(findRoot(input.workspaceDirectory), input.folder) };
|
|
71
|
+
}
|
package/server/open.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
|
|
|
7
7
|
import { findAttachment, imageMimeType, previewKind } from "../shared/attachments.ts";
|
|
8
8
|
import { readProgress } from "./progress-file.ts";
|
|
9
9
|
import { findRoot, outside } from "./paths.ts";
|
|
10
|
+
import { findStore } from "./store.ts";
|
|
10
11
|
|
|
11
12
|
export type Opener = (path: string) => Promise<void>;
|
|
12
13
|
|
|
@@ -44,7 +45,7 @@ export const systemOpener: Opener = async (path) => {
|
|
|
44
45
|
// worktree resolve.
|
|
45
46
|
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
|
|
46
47
|
const worktree = findRoot(directory);
|
|
47
|
-
const { dashboard } = await readProgress(worktree);
|
|
48
|
+
const { dashboard } = await readProgress((await findStore(worktree)).directory);
|
|
48
49
|
const path = findAttachment(dashboard, ref)?.path;
|
|
49
50
|
if (!path) throw new Error(`No local ${ref.startsWith("Q") ? "attachment" : "deliverable"} ${ref} in this worktree.`);
|
|
50
51
|
const root = await realpath(worktree);
|
package/server/progress-file.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
// Everything that reads or writes a worktree's progress files: the event log
|
|
2
|
-
// the command appends to
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
47
|
-
const parsed = await readParsed(
|
|
48
|
-
const panelOpened = await exists(join(
|
|
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
|
-
|
|
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
|
|
69
|
-
return withLock(join(
|
|
70
|
-
const text = (await readText(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
95
|
-
if (error.code !== "EEXIST") throw error;
|
|
96
|
-
});
|
|
80
|
+
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
async function readText(
|
|
83
|
+
async function readText(directory: string): Promise<string | null> {
|
|
100
84
|
try {
|
|
101
|
-
return await readFile(join(
|
|
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(
|
|
109
|
-
const info = await stat(join(
|
|
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(
|
|
98
|
+
parsedFiles.delete(directory);
|
|
115
99
|
return null;
|
|
116
100
|
}
|
|
117
|
-
const cached = parsedFiles.get(
|
|
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(
|
|
104
|
+
const text = await readText(directory);
|
|
121
105
|
if (text === null) {
|
|
122
|
-
parsedFiles.delete(
|
|
106
|
+
parsedFiles.delete(directory);
|
|
123
107
|
return null;
|
|
124
108
|
}
|
|
125
109
|
const parsed = parseProgress(text);
|
|
126
|
-
parsedFiles.set(
|
|
110
|
+
parsedFiles.set(directory, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
|
|
127
111
|
return parsed;
|
|
128
112
|
}
|
|
129
113
|
|