@levistudio/redline 0.1.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/src/sidecar.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { readFile, writeFile, mkdir } from "fs/promises";
2
+ import { closeSync, existsSync, openSync } from "fs";
3
+ import path from "path";
4
+ import lockfile from "proper-lockfile";
5
+
6
+ export interface ThreadEntry {
7
+ role: "human" | "agent";
8
+ name?: string;
9
+ message: string;
10
+ at: string;
11
+ // Agent replies carry a verdict on whether the comment, once resolved,
12
+ // implies an edit to the document. Used to decide whether the round-level
13
+ // action defaults to "Revise" or "Accept as-is". Only set on agent entries.
14
+ requires_revision?: boolean;
15
+ revision_reason?: string;
16
+ }
17
+
18
+ // Latest agent verdict on a comment thread:
19
+ // - 'revise' → an agent reply set requires_revision: true
20
+ // - 'accept' → an agent reply set requires_revision: false
21
+ // - null → no agent reply yet (treat as accept per UX spec; the human
22
+ // resolved unilaterally so they're saying "doesn't matter")
23
+ export function latestVerdict(comment: Comment): "revise" | "accept" | null {
24
+ for (let i = comment.thread.length - 1; i >= 0; i--) {
25
+ const e = comment.thread[i];
26
+ if (e.role !== "agent") continue;
27
+ if (typeof e.requires_revision !== "boolean") continue;
28
+ return e.requires_revision ? "revise" : "accept";
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export interface Comment {
34
+ id: string;
35
+ quote: string;
36
+ context_before: string;
37
+ context_after: string;
38
+ thread: ThreadEntry[];
39
+ resolved: boolean;
40
+ }
41
+
42
+ export interface Round {
43
+ round: number;
44
+ started_at: string;
45
+ submitted_at: string | null; // human clicked "Submit for review" — agent should respond
46
+ agent_replied_at: string | null; // agent posted replies — human should review
47
+ resolved_at: string | null; // human accepted — agent should revise the document
48
+ comments: Comment[];
49
+ }
50
+
51
+ export interface Sidecar {
52
+ file: string;
53
+ context?: string;
54
+ rounds: Round[];
55
+ }
56
+
57
+ function sidecarPath(filePath: string): string {
58
+ const dir = path.join(path.dirname(filePath), ".review");
59
+ const base = path.basename(filePath) + ".json";
60
+ return path.join(dir, base);
61
+ }
62
+
63
+ export async function loadSidecar(filePath: string): Promise<Sidecar> {
64
+ const sp = sidecarPath(filePath);
65
+ if (existsSync(sp)) {
66
+ const raw = await readFile(sp, "utf-8");
67
+ // withSidecar may touch an empty sidecar file before the first save
68
+ // (lockfile needs a target that exists); treat that as the empty shape.
69
+ if (raw.trim() === "") {
70
+ return { file: path.basename(filePath), rounds: [] };
71
+ }
72
+ return JSON.parse(raw) as Sidecar;
73
+ }
74
+ return {
75
+ file: path.basename(filePath),
76
+ rounds: [],
77
+ };
78
+ }
79
+
80
+ export async function saveSidecar(
81
+ filePath: string,
82
+ sidecar: Sidecar
83
+ ): Promise<void> {
84
+ const sp = sidecarPath(filePath);
85
+ await mkdir(path.dirname(sp), { recursive: true });
86
+ await writeFile(sp, JSON.stringify(sidecar, null, 2), "utf-8");
87
+ }
88
+
89
+ // Two layers of mutex around the load → mutate → save cycle:
90
+ //
91
+ // 1. Per-process queue (in-memory) — fast path that serializes calls inside
92
+ // one redline process. The previous version used only this; two POSTs
93
+ // against the same file would interleave their load/save pair without it.
94
+ //
95
+ // 2. Per-file lock (proper-lockfile, on `<sidecar>.lock`) — slow path that
96
+ // serializes calls across processes. Without this, two `redline` instances
97
+ // on the same file (two terminals, a forgotten background instance, a
98
+ // skill invocation while a manual run is open) silently trample each
99
+ // other's writes. The in-memory queue inside each process never sees the
100
+ // other process exists.
101
+ //
102
+ // Layering matters: every transaction acquires the file lock, but only one
103
+ // in-flight per process competes for it. Without the in-memory queue, a
104
+ // flurry of POSTs in one process would all contend on the file lock and
105
+ // serialize through filesystem operations (slow, can stall on macOS).
106
+ const sidecarLocks = new Map<string, Promise<unknown>>();
107
+
108
+ function lockTargetFor(filePath: string): string {
109
+ // proper-lockfile creates `<file>.lock` next to the target. We lock the
110
+ // sidecar JSON itself, not the doc, since the sidecar is the contended
111
+ // resource. proper-lockfile requires the target to exist; touch it if
112
+ // it's the first transaction on a brand-new file.
113
+ return sidecarPath(filePath);
114
+ }
115
+
116
+ async function ensureLockTarget(filePath: string): Promise<string> {
117
+ const sp = lockTargetFor(filePath);
118
+ await mkdir(path.dirname(sp), { recursive: true });
119
+ if (!existsSync(sp)) {
120
+ // Touch — empty file is fine, loadSidecar treats missing-or-empty alike.
121
+ closeSync(openSync(sp, "a"));
122
+ }
123
+ return sp;
124
+ }
125
+
126
+ /**
127
+ * Run `fn` against the sidecar with a per-file mutex held across the
128
+ * load → mutate → save cycle. The mutator may return a value (passed through
129
+ * to the caller) and/or `false` to skip the save (e.g. when validation fails
130
+ * before any mutation happened — avoids a redundant disk write).
131
+ *
132
+ * Lock scope spans both processes (file lock) and intra-process callers
133
+ * (in-memory queue), so concurrent redline instances on the same file
134
+ * serialize correctly.
135
+ */
136
+ export async function withSidecar<T>(
137
+ filePath: string,
138
+ fn: (sidecar: Sidecar) => T | Promise<T>
139
+ ): Promise<T> {
140
+ const key = path.resolve(filePath);
141
+ const prev = sidecarLocks.get(key) ?? Promise.resolve();
142
+ const next = prev.then(async () => {
143
+ const lockTarget = await ensureLockTarget(filePath);
144
+ // retries.forever: true is a deliberate choice — withSidecar callers
145
+ // expect to succeed eventually. Inter-process contention should be rare
146
+ // (two redline instances on the same file is itself unusual); when it
147
+ // happens, waiting is the right behavior, not failing.
148
+ const release = await lockfile.lock(lockTarget, {
149
+ retries: { retries: 30, factor: 1.5, minTimeout: 50, maxTimeout: 1000 },
150
+ stale: 30_000, // a process holding the lock >30s is presumed dead
151
+ });
152
+ try {
153
+ const sidecar = await loadSidecar(filePath);
154
+ const result = await fn(sidecar);
155
+ // Skip the save if the mutator explicitly returned false. Useful for
156
+ // validate-only paths that bail before mutating; keeps the lock scope
157
+ // honest (we still serialized) without the wasted write.
158
+ if ((result as unknown) !== false) {
159
+ await saveSidecar(filePath, sidecar);
160
+ }
161
+ return result;
162
+ } finally {
163
+ await release().catch(() => {});
164
+ }
165
+ });
166
+ // Catch errors so one failed mutator doesn't poison the queue for the next
167
+ // caller. The original `next` promise still rejects for the current caller.
168
+ sidecarLocks.set(key, next.catch(() => {}));
169
+ return next;
170
+ }
171
+
172
+ export function activeRound(sidecar: Sidecar): Round | null {
173
+ return sidecar.rounds.find((r) => r.resolved_at === null) ?? null;
174
+ }
175
+
176
+ export function getOrCreateActiveRound(sidecar: Sidecar): Round {
177
+ const existing = activeRound(sidecar);
178
+ if (existing) return existing;
179
+
180
+ const round: Round = {
181
+ round: sidecar.rounds.length + 1,
182
+ started_at: new Date().toISOString(),
183
+ submitted_at: null,
184
+ agent_replied_at: null,
185
+ resolved_at: null,
186
+ comments: [],
187
+ };
188
+ sidecar.rounds.push(round);
189
+ return round;
190
+ }