@archastro/astroshot-review 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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/bin/astroshot-review.mjs +17 -0
  4. package/dist/cli.d.ts +12 -0
  5. package/dist/cli.js +240 -0
  6. package/dist/data/friction.d.ts +22 -0
  7. package/dist/data/friction.js +278 -0
  8. package/dist/data/hash-cache.d.ts +17 -0
  9. package/dist/data/hash-cache.js +51 -0
  10. package/dist/data/index-cache.d.ts +22 -0
  11. package/dist/data/index-cache.js +64 -0
  12. package/dist/data/manifest.d.ts +41 -0
  13. package/dist/data/manifest.js +105 -0
  14. package/dist/data/model.d.ts +96 -0
  15. package/dist/data/model.js +1 -0
  16. package/dist/data/paths.d.ts +27 -0
  17. package/dist/data/paths.js +98 -0
  18. package/dist/data/review-store.d.ts +67 -0
  19. package/dist/data/review-store.js +237 -0
  20. package/dist/data/scan.d.ts +32 -0
  21. package/dist/data/scan.js +227 -0
  22. package/dist/data/store.d.ts +92 -0
  23. package/dist/data/store.js +408 -0
  24. package/dist/data/watcher.d.ts +37 -0
  25. package/dist/data/watcher.js +126 -0
  26. package/dist/images/halfblocks.d.ts +10 -0
  27. package/dist/images/halfblocks.js +21 -0
  28. package/dist/images/png.d.ts +14 -0
  29. package/dist/images/png.js +45 -0
  30. package/dist/images/scale.d.ts +31 -0
  31. package/dist/images/scale.js +102 -0
  32. package/dist/images/service.d.ts +54 -0
  33. package/dist/images/service.js +163 -0
  34. package/dist/images/worker.d.ts +21 -0
  35. package/dist/images/worker.js +30 -0
  36. package/dist/index.d.ts +12 -0
  37. package/dist/index.js +9 -0
  38. package/dist/terminal/graphics-stdout.d.ts +9 -0
  39. package/dist/terminal/graphics-stdout.js +36 -0
  40. package/dist/terminal/herdr.d.ts +61 -0
  41. package/dist/terminal/herdr.js +327 -0
  42. package/dist/terminal/image-layer.d.ts +122 -0
  43. package/dist/terminal/image-layer.js +471 -0
  44. package/dist/terminal/kitty.d.ts +74 -0
  45. package/dist/terminal/kitty.js +112 -0
  46. package/dist/terminal/probe.d.ts +49 -0
  47. package/dist/terminal/probe.js +206 -0
  48. package/dist/ui/app.d.ts +6 -0
  49. package/dist/ui/app.js +695 -0
  50. package/dist/ui/chrome.d.ts +53 -0
  51. package/dist/ui/chrome.js +69 -0
  52. package/dist/ui/context.d.ts +16 -0
  53. package/dist/ui/context.js +8 -0
  54. package/dist/ui/detail.d.ts +33 -0
  55. package/dist/ui/detail.js +39 -0
  56. package/dist/ui/friction.d.ts +42 -0
  57. package/dist/ui/friction.js +84 -0
  58. package/dist/ui/help.d.ts +4 -0
  59. package/dist/ui/help.js +61 -0
  60. package/dist/ui/hooks.d.ts +9 -0
  61. package/dist/ui/hooks.js +32 -0
  62. package/dist/ui/movie-player.d.ts +29 -0
  63. package/dist/ui/movie-player.js +119 -0
  64. package/dist/ui/picture.d.ts +19 -0
  65. package/dist/ui/picture.js +100 -0
  66. package/dist/ui/selectors.d.ts +26 -0
  67. package/dist/ui/selectors.js +69 -0
  68. package/dist/ui/settings.d.ts +5 -0
  69. package/dist/ui/settings.js +17 -0
  70. package/dist/ui/stream.d.ts +38 -0
  71. package/dist/ui/stream.js +118 -0
  72. package/dist/ui/system.d.ts +4 -0
  73. package/dist/ui/system.js +34 -0
  74. package/dist/ui/takeover.d.ts +26 -0
  75. package/dist/ui/takeover.js +29 -0
  76. package/dist/ui/text-input.d.ts +8 -0
  77. package/dist/ui/text-input.js +74 -0
  78. package/dist/ui/theme.d.ts +21 -0
  79. package/dist/ui/theme.js +63 -0
  80. package/dist/video/ffmpeg.d.ts +54 -0
  81. package/dist/video/ffmpeg.js +206 -0
  82. package/package.json +71 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * `review.json` — the human side of the on-disk contract.
3
+ *
4
+ * Reads mirror the macOS app exactly: version gate, run-id gate, then hash
5
+ * scoping. Writes mirror it too: sorted keys, second-precision UTC
6
+ * timestamps, uppercase UUID comment ids, run reset on a run-id change, and
7
+ * an atomic temp-file rename.
8
+ */
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ export const REVIEW_FILE = "review.json";
13
+ export class UnsupportedReviewVersion extends Error {
14
+ constructor(version) {
15
+ super(`Unsupported review.json version ${String(version)}`);
16
+ }
17
+ }
18
+ function isRecord(value) {
19
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+ export function emptyDocument() {
22
+ return { version: 1, reviews: {} };
23
+ }
24
+ /** Missing file → empty document. Malformed JSON → null (treated as unreadable). */
25
+ export async function readReviewDocument(directory) {
26
+ let raw;
27
+ try {
28
+ raw = await fs.promises.readFile(path.join(directory, REVIEW_FILE), "utf8");
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return emptyDocument();
33
+ return null;
34
+ }
35
+ return parseReviewDocument(raw);
36
+ }
37
+ export function parseReviewDocument(raw) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(raw);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (!isRecord(parsed))
46
+ return null;
47
+ if (parsed.version !== 1)
48
+ throw new UnsupportedReviewVersion(parsed.version);
49
+ const reviews = {};
50
+ if (isRecord(parsed.reviews)) {
51
+ for (const [fileName, entry] of Object.entries(parsed.reviews)) {
52
+ if (!isRecord(entry))
53
+ continue;
54
+ const comments = Array.isArray(entry.comments)
55
+ ? entry.comments.filter(isRecord).map((comment) => ({
56
+ id: String(comment.id ?? ""),
57
+ body: String(comment.body ?? ""),
58
+ created_at: String(comment.created_at ?? ""),
59
+ }))
60
+ : undefined;
61
+ reviews[fileName] = {
62
+ decision: typeof entry.decision === "string" ? entry.decision : undefined,
63
+ reviewed_at: typeof entry.reviewed_at === "string" ? entry.reviewed_at : undefined,
64
+ image_sha256: typeof entry.image_sha256 === "string" ? entry.image_sha256 : undefined,
65
+ comments,
66
+ };
67
+ }
68
+ }
69
+ return {
70
+ version: 1,
71
+ run_id: typeof parsed.run_id === "string" ? parsed.run_id : undefined,
72
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined,
73
+ reviews,
74
+ };
75
+ }
76
+ export function sha256File(filePath) {
77
+ return new Promise((resolve, reject) => {
78
+ const hash = createHash("sha256");
79
+ const stream = fs.createReadStream(filePath);
80
+ stream.on("error", reject);
81
+ stream.on("data", (chunk) => hash.update(chunk));
82
+ stream.on("end", () => resolve(hash.digest("hex")));
83
+ });
84
+ }
85
+ export function sha256Bytes(bytes) {
86
+ return createHash("sha256").update(bytes).digest("hex");
87
+ }
88
+ /** The entry that applies to `fileName`, or null when the run gate rejects it. */
89
+ export function scopedEntry(document, fileName, expectedRunId) {
90
+ if (expectedRunId !== null && document.run_id !== expectedRunId)
91
+ return null;
92
+ return document.reviews[fileName] ?? null;
93
+ }
94
+ /** Whether validating this entry needs the image's current hash. */
95
+ export function entryNeedsHash(entry) {
96
+ return Boolean(entry?.image_sha256);
97
+ }
98
+ /**
99
+ * The seen/stale truth table from the app's `ReviewSnapshot`:
100
+ * hash mismatch hides the decision but keeps comments and flags staleness.
101
+ */
102
+ export function snapshotFromEntry(entry, currentSha256) {
103
+ const hashMatches = Boolean(entry?.image_sha256) && entry?.image_sha256 === currentSha256;
104
+ const decision = entry?.decision ?? null;
105
+ const effective = hashMatches ? decision : null;
106
+ return {
107
+ state: effective === "seen" || effective === "approved" ? "seen" : "pending",
108
+ decision,
109
+ hashMatches,
110
+ isStale: decision !== null && !hashMatches,
111
+ comments: (entry?.comments ?? []).map((comment) => ({
112
+ id: comment.id,
113
+ body: comment.body,
114
+ createdAt: comment.created_at,
115
+ })),
116
+ reviewedAt: entry?.reviewed_at ?? null,
117
+ };
118
+ }
119
+ export function nowIso(date = new Date()) {
120
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
121
+ }
122
+ export function newCommentId() {
123
+ return randomUUID().toUpperCase();
124
+ }
125
+ function sortKeys(value) {
126
+ if (Array.isArray(value))
127
+ return value.map(sortKeys);
128
+ if (isRecord(value)) {
129
+ const sorted = {};
130
+ for (const key of Object.keys(value).sort()) {
131
+ const inner = value[key];
132
+ if (inner !== undefined)
133
+ sorted[key] = sortKeys(inner);
134
+ }
135
+ return sorted;
136
+ }
137
+ return value;
138
+ }
139
+ export function serializeReviewDocument(document) {
140
+ return `${JSON.stringify(sortKeys(document), null, 2)}\n`;
141
+ }
142
+ export async function writeReviewDocument(directory, document) {
143
+ const target = path.join(directory, REVIEW_FILE);
144
+ const temp = path.join(directory, `.review.tmp.${randomUUID()}`);
145
+ await fs.promises.writeFile(temp, serializeReviewDocument(document), { flag: "wx" });
146
+ try {
147
+ await fs.promises.rename(temp, target);
148
+ }
149
+ catch (error) {
150
+ await fs.promises.rm(temp, { force: true });
151
+ throw error;
152
+ }
153
+ }
154
+ /** A run-id change starts a fresh review map, exactly like the app. */
155
+ export function resetReviewsIfNeeded(document, runId) {
156
+ if (runId !== null && document.run_id !== runId) {
157
+ document.run_id = runId;
158
+ document.reviews = {};
159
+ }
160
+ }
161
+ async function stampOf(filePath) {
162
+ try {
163
+ const stat = await fs.promises.stat(filePath);
164
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ function sameStamp(a, b) {
171
+ return a?.mtimeMs === b?.mtimeMs && a?.size === b?.size;
172
+ }
173
+ async function loadForWrite(directory) {
174
+ const stamp = await stampOf(path.join(directory, REVIEW_FILE));
175
+ const document = await readReviewDocument(directory);
176
+ if (document === null) {
177
+ throw new Error(`review.json in ${directory} is not valid JSON; refusing to overwrite it`);
178
+ }
179
+ return { document, stamp };
180
+ }
181
+ /**
182
+ * Read → mutate → write, re-reading when another writer (the macOS app, a
183
+ * second tray) changed the file in between so neither side's update is lost.
184
+ */
185
+ async function mutateReviewDocument(directory, mutate) {
186
+ const target = path.join(directory, REVIEW_FILE);
187
+ for (let attempt = 0; attempt < 5; attempt += 1) {
188
+ const { document, stamp } = await loadForWrite(directory);
189
+ const result = await mutate(document);
190
+ if (!sameStamp(stamp, await stampOf(target)))
191
+ continue;
192
+ await writeReviewDocument(directory, document);
193
+ return result;
194
+ }
195
+ throw new Error(`review.json in ${directory} kept changing underneath this write; try again`);
196
+ }
197
+ function appendComment(entry, body, now) {
198
+ const trimmed = body.trim();
199
+ if (!trimmed)
200
+ throw new Error("Feedback cannot be empty");
201
+ const comment = { id: newCommentId(), body: trimmed, created_at: now };
202
+ entry.comments = [...(entry.comments ?? []), comment];
203
+ return { id: comment.id, body: comment.body, createdAt: comment.created_at };
204
+ }
205
+ export async function markSeen(request, options = {}) {
206
+ const sha = await sha256File(request.targetPath);
207
+ return mutateReviewDocument(request.directory, (document) => {
208
+ resetReviewsIfNeeded(document, request.runId);
209
+ const now = nowIso(options.now);
210
+ const entry = document.reviews[request.fileName] ?? {};
211
+ if (options.comment?.trim())
212
+ appendComment(entry, options.comment, now);
213
+ entry.decision = "seen";
214
+ entry.reviewed_at = now;
215
+ entry.image_sha256 = sha;
216
+ document.reviews[request.fileName] = entry;
217
+ document.updated_at = now;
218
+ return snapshotFromEntry(entry, sha);
219
+ });
220
+ }
221
+ export async function addComment(request, body, options = {}) {
222
+ if (!body.trim())
223
+ throw new Error("Feedback cannot be empty");
224
+ const entry = await mutateReviewDocument(request.directory, (document) => {
225
+ resetReviewsIfNeeded(document, request.runId);
226
+ const now = nowIso(options.now);
227
+ const stored = document.reviews[request.fileName] ?? {};
228
+ appendComment(stored, body, now);
229
+ document.reviews[request.fileName] = stored;
230
+ document.updated_at = now;
231
+ return stored;
232
+ });
233
+ const sha = entry.image_sha256
234
+ ? (options.currentSha256 ?? (await sha256File(request.targetPath)))
235
+ : null;
236
+ return snapshotFromEntry(entry, sha);
237
+ }
@@ -0,0 +1,32 @@
1
+ import type { HashCache } from "./hash-cache.js";
2
+ import { type FeatureManifest } from "./manifest.js";
3
+ import type { AstroshotTree, Shot } from "./model.js";
4
+ import { type ReviewDocument } from "./review-store.js";
5
+ export interface FindOptions {
6
+ maxDepth?: number;
7
+ skip?: Set<string>;
8
+ concurrency?: number;
9
+ onFound?: (astroshotDir: string) => void;
10
+ signal?: AbortSignal;
11
+ }
12
+ /** Breadth-first walk with bounded concurrency; never descends into a found tree. */
13
+ export declare function findAstroshotDirs(roots: string[], options?: FindOptions): Promise<string[]>;
14
+ export interface ShotContext {
15
+ worktreePath: string;
16
+ worktree: string;
17
+ feature: string;
18
+ featureDir: string;
19
+ }
20
+ interface FeatureListing {
21
+ files: Set<string>;
22
+ }
23
+ export declare function buildShot(imagePath: string, context: ShotContext, manifest: FeatureManifest | null, review: ReviewDocument | null, listing: FeatureListing, hashes: HashCache): Promise<Shot | null>;
24
+ /** Every shot inside one feature directory, in directory order. */
25
+ export declare function scanFeatureDir(featureDir: string, context: {
26
+ worktreePath: string;
27
+ worktree: string;
28
+ }, hashes: HashCache): Promise<Shot[]>;
29
+ /** Re-read one shot in place (after its image or sidecars changed). */
30
+ export declare function rebuildShot(imagePath: string, context: ShotContext, hashes: HashCache): Promise<Shot | null>;
31
+ export declare function scanTree(astroshotDir: string, hashes: HashCache): Promise<AstroshotTree>;
32
+ export {};
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Discover `.astroshot` trees under the watch roots and load their shots.
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { loadFrictionLogs } from "./friction.js";
7
+ import { chaptersOf, matchManifestShot, parseFeatureStatus, parseIsoDate, readManifest, } from "./manifest.js";
8
+ import { ASTROSHOT_DIR, FRICTION_DIR, MAX_SCAN_DEPTH, SKIP_DIRECTORIES, VIDEO_EXTENSIONS, frictionLogsDir, humanize, isImageFile, sequenceAndSlug, worktreeShort, } from "./paths.js";
9
+ import { entryNeedsHash, readReviewDocument, scopedEntry, snapshotFromEntry, } from "./review-store.js";
10
+ /** Breadth-first walk with bounded concurrency; never descends into a found tree. */
11
+ export async function findAstroshotDirs(roots, options = {}) {
12
+ const maxDepth = options.maxDepth ?? MAX_SCAN_DEPTH;
13
+ const skip = options.skip ?? SKIP_DIRECTORIES;
14
+ const concurrency = options.concurrency ?? 16;
15
+ const found = [];
16
+ const queue = roots.map((root) => ({ dir: root, depth: 0 }));
17
+ let active = 0;
18
+ await new Promise((resolve) => {
19
+ const pump = () => {
20
+ if (options.signal?.aborted) {
21
+ if (active === 0)
22
+ resolve();
23
+ return;
24
+ }
25
+ while (active < concurrency && queue.length > 0) {
26
+ const item = queue.shift();
27
+ active += 1;
28
+ void visit(item.dir, item.depth).finally(() => {
29
+ active -= 1;
30
+ if (queue.length === 0 && active === 0)
31
+ resolve();
32
+ else
33
+ pump();
34
+ });
35
+ }
36
+ if (queue.length === 0 && active === 0)
37
+ resolve();
38
+ };
39
+ const visit = async (dir, depth) => {
40
+ let entries;
41
+ try {
42
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ const name = entry.name;
49
+ // Follow a linked `.astroshot` itself, but never descend through other
50
+ // symlinks: they can loop and are rarely where captures live.
51
+ if (!entry.isDirectory() && !(name === ASTROSHOT_DIR && (await kindOf(dir, entry)) === "dir"))
52
+ continue;
53
+ if (name === ASTROSHOT_DIR) {
54
+ const astroshotDir = path.join(dir, name);
55
+ found.push(astroshotDir);
56
+ options.onFound?.(astroshotDir);
57
+ continue;
58
+ }
59
+ if (name.startsWith(".") || skip.has(name))
60
+ continue;
61
+ if (depth + 1 > maxDepth)
62
+ continue;
63
+ queue.push({ dir: path.join(dir, name), depth: depth + 1 });
64
+ }
65
+ };
66
+ pump();
67
+ });
68
+ return found.sort();
69
+ }
70
+ /** Dirent kinds with symlinks resolved, so linked trees behave like real ones. */
71
+ async function kindOf(parent, entry) {
72
+ if (entry.isFile())
73
+ return "file";
74
+ if (entry.isDirectory())
75
+ return "dir";
76
+ if (!entry.isSymbolicLink())
77
+ return "other";
78
+ try {
79
+ const stat = await fs.promises.stat(path.join(parent, entry.name));
80
+ return stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "other";
81
+ }
82
+ catch {
83
+ return "other";
84
+ }
85
+ }
86
+ async function listFeature(featureDir) {
87
+ try {
88
+ const entries = await fs.promises.readdir(featureDir, { withFileTypes: true });
89
+ const files = new Set();
90
+ for (const entry of entries) {
91
+ if ((await kindOf(featureDir, entry)) === "file")
92
+ files.add(entry.name);
93
+ }
94
+ return { files };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ function resolveVideo(entryVideo, fileName, files) {
101
+ if (entryVideo && entryVideo.trim())
102
+ return entryVideo;
103
+ const stem = fileName.replace(/\.[^.]+$/, "");
104
+ for (const extension of VIDEO_EXTENSIONS) {
105
+ const candidate = `${stem}.${extension}`;
106
+ if (files.has(candidate))
107
+ return candidate;
108
+ }
109
+ return null;
110
+ }
111
+ async function reviewFor(document, fileName, runId, imagePath, stat, hashes) {
112
+ if (!document)
113
+ return null;
114
+ const entry = scopedEntry(document, fileName, runId);
115
+ let sha = null;
116
+ if (entryNeedsHash(entry)) {
117
+ try {
118
+ sha = await hashes.hash(imagePath, stat);
119
+ }
120
+ catch {
121
+ sha = null;
122
+ }
123
+ }
124
+ return snapshotFromEntry(entry, sha);
125
+ }
126
+ export async function buildShot(imagePath, context, manifest, review, listing, hashes) {
127
+ let stat;
128
+ try {
129
+ stat = await fs.promises.stat(imagePath);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ const fileName = path.basename(imagePath);
135
+ const entry = matchManifestShot(manifest, fileName);
136
+ const parsed = sequenceAndSlug(fileName);
137
+ const slug = entry?.slug ?? parsed.slug;
138
+ const runId = manifest?.run_id ?? null;
139
+ const videoFileName = resolveVideo(entry?.video, fileName, listing.files);
140
+ const videoExists = videoFileName !== null && listing.files.has(videoFileName);
141
+ const durationMs = typeof entry?.duration_ms === "number" && entry.duration_ms > 0 ? entry.duration_ms : null;
142
+ return {
143
+ id: imagePath,
144
+ path: imagePath,
145
+ fileName,
146
+ worktreePath: context.worktreePath,
147
+ worktree: context.worktree,
148
+ worktreeShort: worktreeShort(context.worktree),
149
+ feature: context.feature,
150
+ featureDir: context.featureDir,
151
+ sequence: parsed.sequence ?? entry?.id ?? null,
152
+ slug,
153
+ title: entry?.title ?? humanize(slug),
154
+ description: entry?.description ?? "",
155
+ url: entry?.url ?? null,
156
+ runId,
157
+ status: parseFeatureStatus(manifest?.status),
158
+ capturedAt: parseIsoDate(entry?.captured_at) ?? stat.mtimeMs,
159
+ mtimeMs: stat.mtimeMs,
160
+ isMovie: (entry?.kind ?? "").toLowerCase() === "movie" || videoFileName !== null,
161
+ videoFileName,
162
+ videoPath: videoExists ? path.join(context.featureDir, videoFileName) : null,
163
+ durationMs,
164
+ source: entry?.source ?? null,
165
+ chapters: chaptersOf(entry),
166
+ review: await reviewFor(review, fileName, runId, imagePath, stat, hashes),
167
+ };
168
+ }
169
+ async function readReviewSafely(directory) {
170
+ try {
171
+ return await readReviewDocument(directory);
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ /** Every shot inside one feature directory, in directory order. */
178
+ export async function scanFeatureDir(featureDir, context, hashes) {
179
+ const listing = await listFeature(featureDir);
180
+ if (!listing)
181
+ return [];
182
+ const feature = path.basename(featureDir);
183
+ const [manifest, review] = await Promise.all([readManifest(featureDir), readReviewSafely(featureDir)]);
184
+ const shotContext = { ...context, feature, featureDir };
185
+ const shots = [];
186
+ for (const name of listing.files) {
187
+ if (!isImageFile(name))
188
+ continue;
189
+ const shot = await buildShot(path.join(featureDir, name), shotContext, manifest, review, listing, hashes);
190
+ if (shot)
191
+ shots.push(shot);
192
+ }
193
+ return shots;
194
+ }
195
+ /** Re-read one shot in place (after its image or sidecars changed). */
196
+ export async function rebuildShot(imagePath, context, hashes) {
197
+ const listing = await listFeature(context.featureDir);
198
+ if (!listing)
199
+ return null;
200
+ const [manifest, review] = await Promise.all([
201
+ readManifest(context.featureDir),
202
+ readReviewSafely(context.featureDir),
203
+ ]);
204
+ return buildShot(imagePath, context, manifest, review, listing, hashes);
205
+ }
206
+ export async function scanTree(astroshotDir, hashes) {
207
+ const worktreePath = path.dirname(astroshotDir);
208
+ const worktree = path.basename(worktreePath);
209
+ const context = { worktreePath, worktree };
210
+ let entries = [];
211
+ try {
212
+ entries = await fs.promises.readdir(astroshotDir, { withFileTypes: true });
213
+ }
214
+ catch {
215
+ entries = [];
216
+ }
217
+ const shots = [];
218
+ for (const entry of entries) {
219
+ if (entry.name.startsWith(".") || entry.name === FRICTION_DIR)
220
+ continue;
221
+ if ((await kindOf(astroshotDir, entry)) !== "dir")
222
+ continue;
223
+ shots.push(...(await scanFeatureDir(path.join(astroshotDir, entry.name), context, hashes)));
224
+ }
225
+ const frictionLogs = await loadFrictionLogs(frictionLogsDir(astroshotDir), context, hashes);
226
+ return { astroshotDir, worktreePath, worktree, shots, frictionLogs };
227
+ }
@@ -0,0 +1,92 @@
1
+ import type { FrictionLog, FrictionRun, Shot } from "./model.js";
2
+ import { type WatchEvent } from "./watcher.js";
3
+ export type ScanPhase = "idle" | "warm" | "shallow" | "full";
4
+ /** Depth of the quick pass that finds repo-level trees almost instantly. */
5
+ export declare const SHALLOW_DEPTH = 3;
6
+ /** A deep walk this recent is skipped at startup; `r` always forces one. */
7
+ export declare const FULL_SCAN_TTL_MS: number;
8
+ export interface StoreEvent {
9
+ kind: "new-shot" | "updated-shot";
10
+ shot: Shot;
11
+ at: number;
12
+ }
13
+ export interface StoreState {
14
+ roots: string[];
15
+ shots: Shot[];
16
+ frictionLogs: FrictionLog[];
17
+ treeCount: number;
18
+ scanning: boolean;
19
+ phase: ScanPhase;
20
+ watching: boolean;
21
+ unreadCount: number;
22
+ lastEvent: StoreEvent | null;
23
+ error: string | null;
24
+ revision: number;
25
+ }
26
+ export interface StoreOptions {
27
+ roots: string[];
28
+ indexPath?: string;
29
+ /** Skip the durable index (tests). */
30
+ useIndex?: boolean;
31
+ watch?: boolean;
32
+ settleMs?: number;
33
+ onLog?: (message: string) => void;
34
+ }
35
+ type Listener = () => void;
36
+ export declare class ReviewStore {
37
+ private state;
38
+ private readonly listeners;
39
+ private readonly trees;
40
+ private readonly shotsByPath;
41
+ private arrivalOrder;
42
+ private readonly hashes;
43
+ private watcher;
44
+ private queue;
45
+ private readonly options;
46
+ private index;
47
+ private disposed;
48
+ private saveTimer;
49
+ private fullScanAt;
50
+ /** Arrival order frozen at scan start so batches do not reorder the stream. */
51
+ private scanBaseOrder;
52
+ constructor(options: StoreOptions);
53
+ getState(): StoreState;
54
+ subscribe(listener: Listener): () => void;
55
+ private publish;
56
+ private log;
57
+ /** Serialize mutations. */
58
+ private enqueue;
59
+ start(): Promise<void>;
60
+ /**
61
+ * Warm scan from the index, a shallow walk for repo-level trees, then a
62
+ * deep walk when the index is stale (or when forced by the user).
63
+ */
64
+ rescan(options?: {
65
+ force?: boolean;
66
+ }): Promise<void>;
67
+ private scanTrees;
68
+ /** Rebuild the flat, ordered shot and friction lists from the trees. */
69
+ private recompute;
70
+ private orderedShots;
71
+ private scheduleSave;
72
+ private saveIndexNow;
73
+ handleEvent(event: WatchEvent): Promise<void>;
74
+ private treeFor;
75
+ private ingestShot;
76
+ private refreshFeature;
77
+ private refreshFriction;
78
+ private refreshTree;
79
+ /** The user opened the stream: clear the unread badge. */
80
+ markOpened(): void;
81
+ markShotSeen(shot: Shot, comment?: string): Promise<Shot>;
82
+ addShotComment(shot: Shot, body: string): Promise<Shot>;
83
+ private reloadShot;
84
+ /** Mark many shots seen; failures are counted, never fatal. */
85
+ markManySeen(shots: Shot[]): Promise<{
86
+ ok: number;
87
+ failed: number;
88
+ }>;
89
+ markFrictionRunSeen(log: FrictionLog, run: FrictionRun): Promise<void>;
90
+ dispose(): Promise<void>;
91
+ }
92
+ export {};