@groundtrack/hooks 0.1.26 → 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.
@@ -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;
@@ -0,0 +1,128 @@
1
+ import { join } from "node:path";
2
+ import { stateDir } from "../setup/agents.js";
3
+ import { readFile, writeAtomic } from "../setup/files.js";
4
+ export function watchStatePath(context, source) {
5
+ return join(stateDir(context), `watch-state-${source}.json`);
6
+ }
7
+ export function watchLockPath(context, source) {
8
+ return join(stateDir(context), `watch-${source}.lock`);
9
+ }
10
+ export function emptyWatchState() {
11
+ return { version: 1, files: {}, sessions: {} };
12
+ }
13
+ function isRecord(value) {
14
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+ function isNullableString(value) {
17
+ return value === null || typeof value === "string";
18
+ }
19
+ function isSerializedMetadata(value) {
20
+ if (!isRecord(value))
21
+ return false;
22
+ return (typeof value.externalSessionId === "string" &&
23
+ isNullableString(value.timestamp) &&
24
+ isNullableString(value.cwd) &&
25
+ isNullableString(value.repositoryUrl) &&
26
+ isNullableString(value.repositoryBranch) &&
27
+ isNullableString(value.repositoryCommitSha) &&
28
+ isNullableString(value.originator) &&
29
+ typeof value.isSubagent === "boolean" &&
30
+ (value.historyBaseThreadId === undefined || isNullableString(value.historyBaseThreadId)) &&
31
+ (value.historyMode === undefined || isNullableString(value.historyMode)) &&
32
+ isNullableString(value.startedAt) &&
33
+ isNullableString(value.endedAt));
34
+ }
35
+ function isWatchFileState(value) {
36
+ if (!isRecord(value))
37
+ return false;
38
+ return (typeof value.size === "number" &&
39
+ Number.isFinite(value.size) &&
40
+ typeof value.mtimeMs === "number" &&
41
+ Number.isFinite(value.mtimeMs) &&
42
+ typeof value.sessionId === "string" &&
43
+ (value.status === "active" || value.status === "imported") &&
44
+ isSerializedMetadata(value.metadata) &&
45
+ (value.contentHash === undefined || typeof value.contentHash === "string") &&
46
+ (value.receiptKey === undefined || typeof value.receiptKey === "string") &&
47
+ (value.importedAt === undefined || typeof value.importedAt === "string"));
48
+ }
49
+ function isWatchSessionState(value) {
50
+ if (!isRecord(value))
51
+ return false;
52
+ return (Array.isArray(value.pages) &&
53
+ value.pages.every((page) => typeof page === "string") &&
54
+ typeof value.fingerprint === "string" &&
55
+ (value.status === "active" || value.status === "imported") &&
56
+ (value.contentHash === undefined || typeof value.contentHash === "string") &&
57
+ (value.receiptKey === undefined || typeof value.receiptKey === "string") &&
58
+ (value.importedAt === undefined || typeof value.importedAt === "string"));
59
+ }
60
+ export function serializeMetadata(metadata) {
61
+ return {
62
+ externalSessionId: metadata.externalSessionId,
63
+ timestamp: metadata.timestamp?.toISOString() ?? null,
64
+ cwd: metadata.cwd,
65
+ repositoryUrl: metadata.repositoryUrl,
66
+ repositoryBranch: metadata.repositoryBranch,
67
+ repositoryCommitSha: metadata.repositoryCommitSha,
68
+ originator: metadata.originator,
69
+ isSubagent: metadata.isSubagent,
70
+ historyBaseThreadId: metadata.historyBaseThreadId ?? null,
71
+ historyMode: metadata.historyMode ?? null,
72
+ startedAt: metadata.startedAt?.toISOString() ?? null,
73
+ endedAt: metadata.endedAt?.toISOString() ?? null,
74
+ };
75
+ }
76
+ function parseDate(value) {
77
+ if (value === null)
78
+ return null;
79
+ const date = new Date(value);
80
+ return Number.isNaN(date.getTime()) ? null : date;
81
+ }
82
+ export function deserializeMetadata(metadata) {
83
+ return {
84
+ externalSessionId: metadata.externalSessionId,
85
+ timestamp: parseDate(metadata.timestamp),
86
+ cwd: metadata.cwd,
87
+ repositoryUrl: metadata.repositoryUrl,
88
+ repositoryBranch: metadata.repositoryBranch,
89
+ repositoryCommitSha: metadata.repositoryCommitSha,
90
+ originator: metadata.originator,
91
+ isSubagent: metadata.isSubagent,
92
+ historyBaseThreadId: metadata.historyBaseThreadId ?? null,
93
+ historyMode: metadata.historyMode ?? null,
94
+ startedAt: parseDate(metadata.startedAt),
95
+ endedAt: parseDate(metadata.endedAt),
96
+ };
97
+ }
98
+ export function readWatchState(context, source) {
99
+ const path = watchStatePath(context, source);
100
+ const text = readFile(path);
101
+ if (text === null)
102
+ return emptyWatchState();
103
+ let value;
104
+ try {
105
+ value = JSON.parse(text);
106
+ }
107
+ catch {
108
+ throw new Error(`Groundtrack's watch state is not valid JSON: ${path}`);
109
+ }
110
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.files))
111
+ throw new Error(`Groundtrack's watch state is invalid: ${path}`);
112
+ const files = {};
113
+ for (const [filePath, file] of Object.entries(value.files)) {
114
+ if (isWatchFileState(file))
115
+ files[filePath] = file;
116
+ }
117
+ const sessions = {};
118
+ if (isRecord(value.sessions)) {
119
+ for (const [sessionId, session] of Object.entries(value.sessions)) {
120
+ if (isWatchSessionState(session))
121
+ sessions[sessionId] = session;
122
+ }
123
+ }
124
+ return { version: 1, files, sessions };
125
+ }
126
+ export function writeWatchState(context, source, state) {
127
+ writeAtomic(watchStatePath(context, source), `${JSON.stringify(state, null, 2)}\n`, 0o600);
128
+ }
@@ -0,0 +1,38 @@
1
+ import { type SetupEnvironment } from "../setup/agents.js";
2
+ import { type ImportArguments } from "./cli.js";
3
+ import type { ImportAgent } from "./types.js";
4
+ export declare const WATCH_USAGE = "Usage:\n groundtrack watch --once [--agent codex|claude] [--repo PATH | --all-repos] [--since DATE]\n [--include-subagents] [--include-unscoped]\n [--visibility private|organization] [--dry-run] [--yes] [--json]\n\nWatch options:\n --once Run one scan and exit (required for now)\n --agent VALUE codex or claude\n --repo PATH Watch sessions for this Git repository (default: current repository)\n --all-repos Watch sessions associated with any Git repository\n --include-unscoped With --all-repos, also watch sessions outside a Git repository\n --since DATE Only sessions at or after an ISO timestamp or local YYYY-MM-DD\n --include-subagents Include subagent, forked-agent, and guardian sessions\n --visibility VALUE private (default) or organization\n --api-key KEY API key; prefer GROUNDTRACK_IMPORT_KEY or GROUNDTRACK_API_KEY\n --receipt-scope VALUE Stable receipt scope from create_session_import_key\n --url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)\n --dry-run Scan and plan without uploading or writing cursor state\n --yes, -y Upload completed sessions without prompting\n --json Emit exactly one machine-readable result object\n";
5
+ export type WatchArguments = ImportArguments & Readonly<{
6
+ once: boolean;
7
+ }>;
8
+ export declare function parseWatchArguments(argv: readonly string[]): WatchArguments;
9
+ export type WatchResult = Readonly<{
10
+ dryRun: boolean;
11
+ preview: boolean;
12
+ agent: ImportAgent;
13
+ scope: string;
14
+ scanned: number;
15
+ added: number;
16
+ changed: number;
17
+ removed: number;
18
+ cacheHits: number;
19
+ cacheMisses: number;
20
+ active: number;
21
+ complete: number;
22
+ alreadyImported: number;
23
+ uploaded: number;
24
+ duplicate: number;
25
+ failed: Array<{
26
+ path: string;
27
+ error: string;
28
+ }>;
29
+ skipped: Record<string, number>;
30
+ cursorPath: string;
31
+ sessionsUrl: string;
32
+ }>;
33
+ export type WatchDependencies = Readonly<{
34
+ fetch?: typeof globalThis.fetch;
35
+ now?: () => number;
36
+ }>;
37
+ export declare function runWatchOnce(args: ImportArguments, context: SetupEnvironment, dependencies?: WatchDependencies): Promise<WatchResult>;
38
+ export declare function runWatchCli(argv: readonly string[]): Promise<void>;