@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.
- package/README.md +25 -0
- package/dist/groundtrack.js +6 -1
- package/dist/session-import/canonical.js +1 -1
- package/dist/session-import/cli.d.ts +1 -2
- package/dist/session-import/cli.js +37 -45
- package/dist/session-import/codex-pages.d.ts +22 -0
- package/dist/session-import/codex-pages.js +184 -0
- package/dist/session-import/codex.js +3 -0
- package/dist/session-import/compression.d.ts +8 -0
- package/dist/session-import/compression.js +16 -0
- package/dist/session-import/discovery.d.ts +20 -3
- package/dist/session-import/discovery.js +114 -39
- package/dist/session-import/prune.js +30 -12
- package/dist/session-import/receipts.d.ts +1 -1
- package/dist/session-import/sync.d.ts +32 -0
- package/dist/session-import/sync.js +55 -0
- package/dist/session-import/types.d.ts +3 -0
- package/dist/session-import/watch-lock.d.ts +17 -0
- package/dist/session-import/watch-lock.js +105 -0
- package/dist/session-import/watch-state.d.ts +47 -0
- package/dist/session-import/watch-state.js +128 -0
- package/dist/session-import/watch.d.ts +38 -0
- package/dist/session-import/watch.js +402 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +2 -2
|
@@ -1,45 +1,71 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
+
if (isSessionRecord(record))
|
|
31
|
+
records.push(record);
|
|
34
32
|
}
|
|
35
33
|
catch {
|
|
36
|
-
|
|
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
|
-
|
|
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() &&
|
|
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
|
|
131
|
-
|
|
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
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
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 {
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
73
|
+
originalBytes,
|
|
56
74
|
prunedBytes: headerBytes.byteLength + entryBytes,
|
|
57
75
|
metadata: header,
|
|
58
76
|
};
|
|
@@ -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;
|