@groundtrack/hooks 0.1.25 → 0.1.27

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,45 +1,71 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, realpathSync, } from "node:fs";
2
+ import { createReadStream, existsSync, lstatSync, readdirSync, realpathSync, statSync, } from "node:fs";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { StringDecoder } from "node:string_decoder";
5
6
  import { isSessionRecord } from "./canonical.js";
6
- function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
7
- const file = openSync(path, "r");
8
- try {
9
- const chunks = [];
10
- let offset = 0;
11
- let newlineCount = 0;
12
- while (offset < maxBytes && newlineCount < maxRecords) {
13
- const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes - offset));
14
- const read = readSync(file, chunk, 0, chunk.length, offset);
15
- if (read === 0)
16
- break;
17
- const bytes = chunk.subarray(0, read);
18
- chunks.push(bytes);
19
- offset += read;
20
- for (const byte of bytes) {
21
- if (byte === 10 && ++newlineCount >= maxRecords)
22
- break;
23
- }
24
- }
25
- return Buffer.concat(chunks)
26
- .toString("utf8")
27
- .split(/\r?\n/, maxRecords)
28
- .flatMap((line) => {
29
- if (!line.trim())
30
- return [];
7
+ import { createSessionDecompressor, isCompressedSession } from "./compression.js";
8
+ async function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
9
+ const input = createReadStream(path);
10
+ const source = isCompressedSession(path) ? input.pipe(createSessionDecompressor()) : input;
11
+ // `pipe()` does not forward upstream errors to the destination. Surface them on
12
+ // the stream being iterated so the catch below handles a vanished or unreadable
13
+ // file instead of crashing on an unhandled 'error' event.
14
+ input.on("error", (error) => source.destroy(error));
15
+ const decoder = new StringDecoder("utf8");
16
+ const records = [];
17
+ let pending = "";
18
+ let lineCount = 0;
19
+ let bytes = 0;
20
+ const consume = (line) => {
21
+ const nextCount = lineCount + 1;
22
+ const nextBytes = bytes + Buffer.byteLength(line) + 1;
23
+ if (nextCount > maxRecords || nextBytes > maxBytes)
24
+ return false;
25
+ lineCount = nextCount;
26
+ bytes = nextBytes;
27
+ if (line.trim()) {
31
28
  try {
32
29
  const record = JSON.parse(line);
33
- return isSessionRecord(record) ? [record] : [];
30
+ if (isSessionRecord(record))
31
+ records.push(record);
34
32
  }
35
33
  catch {
36
- return [];
34
+ // Ignore malformed lines, matching the previous behavior.
37
35
  }
38
- });
36
+ }
37
+ return true;
38
+ };
39
+ try {
40
+ for await (const chunk of source) {
41
+ pending += decoder.write(chunk);
42
+ let newline = pending.indexOf("\n");
43
+ while (newline >= 0) {
44
+ const line = pending.slice(0, newline).replace(/\r$/, "");
45
+ pending = pending.slice(newline + 1);
46
+ if (!consume(line))
47
+ return records;
48
+ newline = pending.indexOf("\n");
49
+ }
50
+ // Stop before buffering a line that cannot fit in the byte budget.
51
+ if (bytes + Buffer.byteLength(pending) > maxBytes)
52
+ return records;
53
+ }
54
+ pending += decoder.end();
55
+ if (pending.trim())
56
+ consume(pending.replace(/\r$/, ""));
57
+ }
58
+ catch {
59
+ // A truncated or corrupt compression stream should not abort discovery.
39
60
  }
40
61
  finally {
41
- closeSync(file);
62
+ // `pipe()` does not destroy the source when the destination is destroyed.
63
+ // Close both ends explicitly so an early-stopped `.jsonl.zst` read does not
64
+ // leak a file descriptor.
65
+ source.destroy();
66
+ input.destroy();
42
67
  }
68
+ return records;
43
69
  }
44
70
  function walkJsonl(root) {
45
71
  if (!existsSync(root))
@@ -50,13 +76,35 @@ function walkJsonl(root) {
50
76
  const path = resolve(directory, entry.name);
51
77
  if (entry.isDirectory())
52
78
  visit(path);
53
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".jsonl"))
79
+ else if (entry.isFile() &&
80
+ (entry.name.toLowerCase().endsWith(".jsonl") ||
81
+ entry.name.toLowerCase().endsWith(".jsonl.zst")))
54
82
  result.push(path);
55
83
  }
56
84
  };
57
85
  visit(root);
58
86
  return result.sort();
59
87
  }
88
+ /**
89
+ * A metadata-only snapshot of the session files. This is the cheap half of a
90
+ * watch pass: it walks directories and calls stat, but never opens a session.
91
+ */
92
+ export function statSessionFiles(format, home) {
93
+ const result = new Map();
94
+ if (format.discovery !== "files")
95
+ return result;
96
+ for (const path of format.sessionRoots(home).flatMap(walkJsonl)) {
97
+ try {
98
+ const stat = statSync(path);
99
+ if (stat.isFile())
100
+ result.set(path, { size: stat.size, mtimeMs: stat.mtimeMs });
101
+ }
102
+ catch {
103
+ // A file can disappear between readdir and stat; the next pass will retry it.
104
+ }
105
+ }
106
+ return result;
107
+ }
60
108
  export function normalizeRepositoryUrl(value) {
61
109
  const trimmed = value
62
110
  .trim()
@@ -116,10 +164,13 @@ function isInside(candidate, root) {
116
164
  const child = relative(root, resolve(candidate));
117
165
  return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
118
166
  }
119
- export function discoverSessions(options) {
167
+ export async function discoverSessions(options) {
120
168
  const nowMs = options.nowMs ?? Date.now();
121
169
  const liveWindowMs = options.liveWindowMs ?? 5 * 60_000;
122
170
  const included = [];
171
+ const metadataByPath = new Map();
172
+ let cacheHits = 0;
173
+ let cacheMisses = 0;
123
174
  const discoveryContext = {
124
175
  sessionHome: options.home,
125
176
  userHome: options.userHome ?? homedir(),
@@ -127,20 +178,33 @@ export function discoverSessions(options) {
127
178
  env: options.env ?? process.env,
128
179
  platform: options.platform ?? process.platform,
129
180
  };
130
- const discovered = options.format.discovery === "custom"
131
- ? options.format.discoverCandidates(discoveryContext)
132
- : (() => {
181
+ const format = options.format;
182
+ const discovered = format.discovery === "custom"
183
+ ? format.discoverCandidates(discoveryContext)
184
+ : await (async () => {
133
185
  const candidates = [];
134
186
  const rejected = [];
135
- for (const path of options.format.sessionRoots(options.home).flatMap(walkJsonl)) {
136
- const metadata = options.format.parseDiscoveryMetadata(initialRecords(path, options.format.discoveryRecordLimit));
137
- if (metadata)
187
+ for (const path of format.sessionRoots(options.home).flatMap(walkJsonl)) {
188
+ const cached = options.metadataCache?.get(path);
189
+ if (cached)
190
+ cacheHits += 1;
191
+ else
192
+ cacheMisses += 1;
193
+ const metadata = cached ??
194
+ format.parseDiscoveryMetadata(await initialRecords(path, format.discoveryRecordLimit));
195
+ if (metadata) {
138
196
  candidates.push({ path, metadata });
197
+ metadataByPath.set(path, metadata);
198
+ }
139
199
  else
140
200
  rejected.push({ path, reason: "invalid_metadata" });
141
201
  }
142
202
  return { included: candidates, skipped: rejected };
143
203
  })();
204
+ if (format.discovery === "custom") {
205
+ for (const candidate of discovered.included)
206
+ metadataByPath.set(candidate.path, candidate.metadata);
207
+ }
144
208
  const skipped = [...discovered.skipped];
145
209
  const repositoryRootsByCwd = new Map();
146
210
  const repositoryIdentitiesByRoot = new Map();
@@ -236,16 +300,27 @@ export function discoverSessions(options) {
236
300
  (options.allRepositories ? soleRemote(repositoryIdentityForCwd(metadata.cwd)) : null));
237
301
  included.push({
238
302
  ...candidate,
303
+ modifiedAt: candidate.modifiedAt ?? new Date(modifiedAtMs),
239
304
  metadata: { ...metadata, repositoryUrl: attributedRemote },
240
305
  });
241
306
  }
242
307
  if (!options.format.materializeCandidates)
243
- return { included, skipped, failed: [] };
308
+ return {
309
+ included,
310
+ skipped,
311
+ failed: [],
312
+ metadataByPath,
313
+ cacheHits,
314
+ cacheMisses,
315
+ };
244
316
  const materialized = options.format.materializeCandidates(included, discoveryContext);
245
317
  return {
246
318
  included: [...materialized.included],
247
319
  skipped,
248
320
  failed: [...materialized.failed],
321
+ metadataByPath,
322
+ cacheHits,
323
+ cacheMisses,
249
324
  cleanup: materialized.cleanup,
250
325
  };
251
326
  }
@@ -4,6 +4,7 @@ import { stat } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline";
5
5
  import { Transform } from "node:stream";
6
6
  import { CANONICAL_IMPORT_MAX_RECORD_BYTES } from "./canonical.js";
7
+ import { createSessionDecompressor, isCompressedSession } from "./compression.js";
7
8
  import { createSessionNormalizationAccumulator } from "./normalize.js";
8
9
  async function scanSession(path, format, discoveryMetadata, onEntry) {
9
10
  const before = await stat(path);
@@ -12,6 +13,7 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
12
13
  hash.update(`${format.sourceHashSeed(discoveryMetadata)}\0`);
13
14
  const encoder = new TextEncoder();
14
15
  let entryBytes = 0;
16
+ let originalBytes = 0;
15
17
  const accumulator = createSessionNormalizationAccumulator(format.createNormalizer(discoveryMetadata), {
16
18
  discoveryMetadata,
17
19
  onEntry: (entry) => {
@@ -24,24 +26,40 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
24
26
  });
25
27
  const hashingStream = new Transform({
26
28
  transform(chunk, _encoding, callback) {
29
+ originalBytes += chunk.length;
27
30
  hash.update(chunk);
28
31
  callback(null, chunk);
29
32
  },
30
33
  });
31
- const input = createReadStream(path);
34
+ const rawInput = createReadStream(path);
35
+ const input = isCompressedSession(path) ? rawInput.pipe(createSessionDecompressor()) : rawInput;
36
+ // `pipe()` does not forward upstream errors. Move an error from the file stream
37
+ // to the decoder, then to the hashing stream that readline is consuming, so the
38
+ // loop below observes it instead of the process crashing.
39
+ rawInput.on("error", (error) => input.destroy(error));
40
+ input.on("error", (error) => hashingStream.destroy(error));
32
41
  input.pipe(hashingStream);
33
42
  const lines = createInterface({ input: hashingStream, crlfDelay: Infinity });
34
- for await (const line of lines) {
35
- if (!line.trim())
36
- continue;
37
- let record;
38
- try {
39
- record = JSON.parse(line);
43
+ try {
44
+ for await (const line of lines) {
45
+ if (!line.trim())
46
+ continue;
47
+ let record;
48
+ try {
49
+ record = JSON.parse(line);
50
+ }
51
+ catch {
52
+ record = null;
53
+ }
54
+ accumulator.add(record);
40
55
  }
41
- catch {
42
- record = null;
43
- }
44
- accumulator.add(record);
56
+ }
57
+ finally {
58
+ lines.close();
59
+ hashingStream.destroy();
60
+ input.destroy();
61
+ if (input !== rawInput)
62
+ rawInput.destroy();
45
63
  }
46
64
  const after = await stat(path);
47
65
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
@@ -52,7 +70,7 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
52
70
  throw new Error(`Canonical session metadata is too large: ${path}`);
53
71
  return {
54
72
  sourceContentHash: hash.digest("hex"),
55
- originalBytes: before.size,
73
+ originalBytes,
56
74
  prunedBytes: headerBytes.byteLength + entryBytes,
57
75
  metadata: header,
58
76
  };
@@ -4,7 +4,7 @@ type Receipt = Readonly<{
4
4
  sessionId: string;
5
5
  importedAt: string;
6
6
  }>;
7
- type ReceiptFile = {
7
+ export type ReceiptFile = {
8
8
  version: 1;
9
9
  receipts: Record<string, Receipt>;
10
10
  };
@@ -0,0 +1,32 @@
1
+ import type { SessionImportFormat } from "./format.js";
2
+ import { type ReceiptFile } from "./receipts.js";
3
+ import type { SetupEnvironment } from "../setup/agents.js";
4
+ import type { AgentSessionCandidate, AgentSessionPlan, ImportVisibility } from "./types.js";
5
+ export type PreparedSession = AgentSessionPlan & Readonly<{
6
+ candidate: AgentSessionCandidate;
7
+ receiptKey: string | null;
8
+ alreadyImported: boolean;
9
+ }>;
10
+ export declare function formatBytes(bytes: number): string;
11
+ export declare function prepareSession(input: {
12
+ candidate: AgentSessionCandidate;
13
+ format: SessionImportFormat;
14
+ apiBaseUrl: string;
15
+ apiKey: string | null;
16
+ receiptScope?: string;
17
+ visibility: ImportVisibility;
18
+ receipts: ReceiptFile;
19
+ }): Promise<PreparedSession>;
20
+ export declare function uploadPreparedSession(input: {
21
+ prepared: PreparedSession;
22
+ format: SessionImportFormat;
23
+ apiBaseUrl: string;
24
+ apiKey: string;
25
+ visibility: ImportVisibility;
26
+ context: SetupEnvironment;
27
+ receipts: ReceiptFile;
28
+ fetch?: typeof globalThis.fetch;
29
+ }): Promise<{
30
+ duplicate: boolean;
31
+ sessionId: string;
32
+ }>;
@@ -0,0 +1,55 @@
1
+ import { CANONICAL_IMPORT_MAX_BYTES } from "./canonical.js";
2
+ import { uploadAgentSession } from "./client.js";
3
+ import { pruneSession } from "./prune.js";
4
+ import { receiptKey, saveReceipt } from "./receipts.js";
5
+ export function formatBytes(bytes) {
6
+ if (bytes < 1024)
7
+ return `${bytes} B`;
8
+ if (bytes < 1024 ** 2)
9
+ return `${(bytes / 1024).toFixed(1)} KB`;
10
+ if (bytes < 1024 ** 3)
11
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
12
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
13
+ }
14
+ export async function prepareSession(input) {
15
+ const pruned = await pruneSession(input.candidate.path, input.format, {
16
+ emitBytes: false,
17
+ metadata: input.candidate.metadata,
18
+ });
19
+ if (pruned.prunedBytes > CANONICAL_IMPORT_MAX_BYTES)
20
+ throw new Error(`Pruned session exceeds ${formatBytes(CANONICAL_IMPORT_MAX_BYTES)}`);
21
+ const key = input.apiKey
22
+ ? receiptKey({
23
+ apiBaseUrl: input.apiBaseUrl,
24
+ apiKey: input.apiKey,
25
+ receiptScope: input.receiptScope,
26
+ source: input.format.source,
27
+ sourceContentHash: pruned.sourceContentHash,
28
+ visibility: input.visibility,
29
+ })
30
+ : null;
31
+ return {
32
+ ...pruned,
33
+ candidate: input.candidate,
34
+ receiptKey: key,
35
+ alreadyImported: Boolean(key && input.receipts.receipts[key]),
36
+ };
37
+ }
38
+ export async function uploadPreparedSession(input) {
39
+ if (!input.prepared.receiptKey)
40
+ throw new Error("A session cannot be uploaded without a receipt key.");
41
+ const session = await pruneSession(input.prepared.candidate.path, input.format, {
42
+ plan: input.prepared,
43
+ metadata: input.prepared.candidate.metadata,
44
+ });
45
+ const result = await uploadAgentSession({
46
+ apiBaseUrl: input.apiBaseUrl,
47
+ apiKey: input.apiKey,
48
+ source: input.format.source,
49
+ session,
50
+ visibility: input.visibility,
51
+ fetch: input.fetch,
52
+ });
53
+ saveReceipt(input.context, input.receipts, input.prepared.receiptKey, result.sessionId);
54
+ return result;
55
+ }
@@ -12,6 +12,9 @@ export type AgentSessionMetadata = Readonly<{
12
12
  repositoryCommitSha: string | null;
13
13
  originator: string | null;
14
14
  isSubagent: boolean;
15
+ /** Codex paginated rollouts point at the previous page's thread id. */
16
+ historyBaseThreadId?: string | null;
17
+ historyMode?: string | null;
15
18
  startedAt?: Date | null;
16
19
  endedAt?: Date | null;
17
20
  }>;
@@ -0,0 +1,17 @@
1
+ import type { SetupEnvironment } from "../setup/agents.js";
2
+ import type { AgentSessionSource } from "./types.js";
3
+ export type WatchLock = Readonly<{
4
+ path: string;
5
+ release(): void;
6
+ }>;
7
+ /**
8
+ * A PID lock with stale detection.
9
+ *
10
+ * Creation is atomic (`open(..., "wx")`). Stale takeover never unlinks the
11
+ * observed path directly: it renames the lock to a unique quarantine name,
12
+ * verifies that the quarantined file is the same stale owner, and only then
13
+ * removes it. If another process replaced the lock between observation and
14
+ * takeover, the moved lock is restored and acquisition retries. This prevents
15
+ * two starters from both removing each other's fresh lock.
16
+ */
17
+ export declare function acquireWatchLock(context: SetupEnvironment, source: AgentSessionSource): WatchLock;
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { watchLockPath } from "./watch-state.js";
5
+ function processAlive(pid) {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ }
10
+ catch (error) {
11
+ return error.code === "EPERM";
12
+ }
13
+ }
14
+ function readOwner(path) {
15
+ try {
16
+ if (lstatSync(path).isSymbolicLink())
17
+ throw new Error(`Refusing to use a symbolic link as the watch lock: ${path}`);
18
+ }
19
+ catch (error) {
20
+ if (error.code === "ENOENT")
21
+ return null;
22
+ throw error;
23
+ }
24
+ try {
25
+ return readFileSync(path, "utf8").trim();
26
+ }
27
+ catch (error) {
28
+ if (error.code === "ENOENT")
29
+ return null;
30
+ throw error;
31
+ }
32
+ }
33
+ /**
34
+ * A PID lock with stale detection.
35
+ *
36
+ * Creation is atomic (`open(..., "wx")`). Stale takeover never unlinks the
37
+ * observed path directly: it renames the lock to a unique quarantine name,
38
+ * verifies that the quarantined file is the same stale owner, and only then
39
+ * removes it. If another process replaced the lock between observation and
40
+ * takeover, the moved lock is restored and acquisition retries. This prevents
41
+ * two starters from both removing each other's fresh lock.
42
+ */
43
+ export function acquireWatchLock(context, source) {
44
+ const path = watchLockPath(context, source);
45
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
+ const owner = `${process.pid}:${randomUUID()}`;
47
+ for (let attempt = 0; attempt < 4; attempt += 1) {
48
+ try {
49
+ const fd = openSync(path, "wx", 0o600);
50
+ try {
51
+ writeFileSync(fd, `${owner}\n`);
52
+ }
53
+ finally {
54
+ closeSync(fd);
55
+ }
56
+ if (readOwner(path) !== owner)
57
+ throw new Error(`The Groundtrack watch lock changed during acquisition: ${path}`);
58
+ return {
59
+ path,
60
+ release() {
61
+ try {
62
+ if (readOwner(path) === owner)
63
+ rmSync(path, { force: true });
64
+ }
65
+ catch {
66
+ // The lock was already removed or replaced; never remove another owner's file.
67
+ }
68
+ },
69
+ };
70
+ }
71
+ catch (error) {
72
+ if (error.code !== "EEXIST")
73
+ throw error;
74
+ }
75
+ const observed = readOwner(path);
76
+ if (observed === null)
77
+ continue;
78
+ const pid = Number(observed.split(":", 1)[0]);
79
+ if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
80
+ throw new Error(`Another groundtrack watch is already running (pid ${pid}).`);
81
+ const quarantine = `${path}.stale-${process.pid}-${randomUUID()}`;
82
+ try {
83
+ renameSync(path, quarantine);
84
+ }
85
+ catch (error) {
86
+ if (error.code === "ENOENT")
87
+ continue;
88
+ throw error;
89
+ }
90
+ const moved = readOwner(quarantine);
91
+ if (moved !== observed) {
92
+ // We raced with a fresh owner and moved its lock. Put it back if possible;
93
+ // never delete a lock that does not match the stale owner we observed.
94
+ try {
95
+ renameSync(quarantine, path);
96
+ }
97
+ catch {
98
+ throw new Error(`The Groundtrack watch lock changed during stale takeover. Retry when no other watch is running: ${path}`);
99
+ }
100
+ continue;
101
+ }
102
+ rmSync(quarantine, { force: true });
103
+ }
104
+ throw new Error(`Could not acquire the Groundtrack watch lock: ${path}`);
105
+ }
@@ -0,0 +1,47 @@
1
+ import { type SetupEnvironment } from "../setup/agents.js";
2
+ import type { AgentSessionMetadata, AgentSessionSource } from "./types.js";
3
+ export type WatchFileStatus = "active" | "imported";
4
+ export type SerializedMetadata = Readonly<{
5
+ externalSessionId: string;
6
+ timestamp: string | null;
7
+ cwd: string | null;
8
+ repositoryUrl: string | null;
9
+ repositoryBranch: string | null;
10
+ repositoryCommitSha: string | null;
11
+ originator: string | null;
12
+ isSubagent: boolean;
13
+ historyBaseThreadId?: string | null;
14
+ historyMode?: string | null;
15
+ startedAt: string | null;
16
+ endedAt: string | null;
17
+ }>;
18
+ export type WatchFileState = {
19
+ size: number;
20
+ mtimeMs: number;
21
+ sessionId: string;
22
+ metadata: SerializedMetadata;
23
+ status: WatchFileStatus;
24
+ contentHash?: string;
25
+ receiptKey?: string;
26
+ importedAt?: string;
27
+ };
28
+ export type WatchSessionState = {
29
+ pages: string[];
30
+ fingerprint: string;
31
+ status: WatchFileStatus;
32
+ contentHash?: string;
33
+ receiptKey?: string;
34
+ importedAt?: string;
35
+ };
36
+ export type WatchState = {
37
+ version: 1;
38
+ files: Record<string, WatchFileState>;
39
+ sessions: Record<string, WatchSessionState>;
40
+ };
41
+ export declare function watchStatePath(context: SetupEnvironment, source: AgentSessionSource): string;
42
+ export declare function watchLockPath(context: SetupEnvironment, source: AgentSessionSource): string;
43
+ export declare function emptyWatchState(): WatchState;
44
+ export declare function serializeMetadata(metadata: AgentSessionMetadata): SerializedMetadata;
45
+ export declare function deserializeMetadata(metadata: SerializedMetadata): AgentSessionMetadata;
46
+ export declare function readWatchState(context: SetupEnvironment, source: AgentSessionSource): WatchState;
47
+ export declare function writeWatchState(context: SetupEnvironment, source: AgentSessionSource, state: WatchState): void;