{"version":3,"file":"change-signature.d.ts","sourceRoot":"","sources":["../../../src/watchdog/change-signature.ts"],"names":[],"mappings":"AAgCA,MAAM,WAAW,2BAA2B;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,EAAE,CAAC;CACvB;AA2JD,wBAAgB,kCAAkC,CAAC,GAAG,EAAE,MAAM,GAAG,2BAA2B,GAAG,SAAS,CAWvG;AAoBD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAY9D","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\n\nconst IGNORED_CHANGE_PREFIXES = [\".pi-subagents/\", \"tmp/\", \"node_modules/\"];\nconst IGNORED_CHANGE_PATHS = new Set([\".pi-subagents\", \"tmp\", \"node_modules\"]);\nconst IGNORED_CHANGE_SEGMENTS = new Set([\".git\", \".pi-subagents\", \"node_modules\"]);\n\nconst DEFAULT_MAX_HASH_FILE_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_HASH_TOTAL_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_HASH_ENTRIES = 2_000;\n\nfunction positiveEnvNumber(name: string, fallback: number): number {\n\tconst parsed = Number(process.env[name]);\n\treturn Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;\n}\n\n// Read at call time (not module load) so tests can override env guards after\n// this module is imported.\nfunction maxHashFileBytes(): number {\n\treturn positiveEnvNumber(\"PI_SUBAGENTS_MAX_HASH_FILE_BYTES\", DEFAULT_MAX_HASH_FILE_BYTES);\n}\n\nfunction maxHashTotalBytes(): number {\n\treturn positiveEnvNumber(\"PI_SUBAGENTS_MAX_HASH_TOTAL_BYTES\", DEFAULT_MAX_HASH_TOTAL_BYTES);\n}\n\nfunction maxHashEntries(): number {\n\treturn positiveEnvNumber(\"PI_SUBAGENTS_MAX_HASH_ENTRIES\", DEFAULT_MAX_HASH_ENTRIES);\n}\n\nexport interface WatchdogRepoChangeSignature {\n\troot: string;\n\tkey: string;\n\tchangedPaths: string[];\n}\n\nfunction git(cwd: string, args: string[]): string | undefined {\n\tconst result = spawnSync(\"git\", [\"-C\", cwd, ...args], { encoding: \"utf-8\", maxBuffer: 10 * 1024 * 1024 });\n\tif (result.status !== 0) return undefined;\n\treturn result.stdout;\n}\n\nfunction normalizeRelPath(value: string): string {\n\treturn value.replaceAll(path.sep, \"/\").replace(/^\\.\\//, \"\");\n}\n\nfunction ignoredRelPath(relPath: string): boolean {\n\tconst normalized = normalizeRelPath(relPath);\n\treturn (\n\t\tIGNORED_CHANGE_PATHS.has(normalized) ||\n\t\tIGNORED_CHANGE_PREFIXES.some((prefix) => normalized.startsWith(prefix)) ||\n\t\tnormalized.split(\"/\").some((segment) => IGNORED_CHANGE_SEGMENTS.has(segment))\n\t);\n}\n\ninterface HashBudget {\n\tentries: number;\n\tbytes: number;\n\tmaxEntries: number;\n\tmaxBytes: number;\n}\n\nfunction useHashEntryBudget(budget: HashBudget): boolean {\n\tif (budget.entries >= budget.maxEntries) return false;\n\tbudget.entries++;\n\treturn true;\n}\n\nfunction hashFile(filePath: string): string {\n\treturn createHash(\"sha256\").update(fs.readFileSync(filePath)).digest(\"hex\");\n}\n\nfunction largeFileHash(stat: fs.Stats): string {\n\treturn `large:${stat.size}:${Math.floor(stat.mtimeMs)}`;\n}\n\nfunction hashFileEntry(normalized: string, fullPath: string, stat: fs.Stats, budget: HashBudget): unknown {\n\tlet hash: string;\n\tif (stat.size > maxHashFileBytes() || budget.bytes + stat.size > budget.maxBytes) {\n\t\thash = largeFileHash(stat);\n\t} else {\n\t\ttry {\n\t\t\thash = hashFile(fullPath);\n\t\t\tbudget.bytes += stat.size;\n\t\t} catch (error) {\n\t\t\tconst code = (error as NodeJS.ErrnoException).code;\n\t\t\t// A file racing away between lstat and read: mirror the lstat ENOENT path.\n\t\t\tif (code === \"ENOENT\") return { path: normalized, state: \"deleted\" };\n\t\t\t// Any other read failure (too-large, EACCES, EISDIR, ...) degrades to the\n\t\t\t// metadata marker so one unreadable file never discards the whole signature.\n\t\t\thash = largeFileHash(stat);\n\t\t\tif (code !== \"ERR_FS_FILE_TOO_LARGE\") {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t\"[pi-subagents] watchdog hashFile fell back to metadata for\",\n\t\t\t\t\t`${normalized}:`,\n\t\t\t\t\t(error as Error)?.message,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\treturn { path: normalized, state: \"file\", mode: stat.mode & 0o777, size: stat.size, hash };\n}\n\nfunction gitWorktreeEntry(normalized: string, fullPath: string): unknown {\n\tconst status = git(fullPath, [\"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=no\"]);\n\treturn {\n\t\tpath: normalized,\n\t\tstate: \"git-worktree\",\n\t\thead: git(fullPath, [\"rev-parse\", \"HEAD\"])?.trim(),\n\t\tdirty: Boolean(status),\n\t\tstatusKey: status ? createHash(\"sha256\").update(status).digest(\"hex\") : undefined,\n\t};\n}\n\nfunction hashPath(root: string, relPath: string, budget: HashBudget): unknown {\n\tconst normalized = normalizeRelPath(relPath);\n\tif (!useHashEntryBudget(budget)) return { path: normalized, state: \"skipped\", reason: \"entry-limit\" };\n\tconst fullPath = path.join(root, normalized);\n\tlet stat: fs.Stats;\n\ttry {\n\t\tstat = fs.lstatSync(fullPath);\n\t} catch (error) {\n\t\tif ((error as NodeJS.ErrnoException).code === \"ENOENT\") return { path: normalized, state: \"deleted\" };\n\t\tthrow error;\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\treturn { path: normalized, state: \"symlink\", target: fs.readlinkSync(fullPath) };\n\t}\n\tif (stat.isDirectory()) {\n\t\tif (fs.existsSync(path.join(fullPath, \".git\"))) return gitWorktreeEntry(normalized, fullPath);\n\t\tconst entries = fs\n\t\t\t.readdirSync(fullPath)\n\t\t\t.map((entry) => normalizeRelPath(path.posix.join(normalized, entry)))\n\t\t\t.filter((entry) => !ignoredRelPath(entry))\n\t\t\t.sort();\n\t\tconst remainingEntries = Math.max(0, budget.maxEntries - budget.entries);\n\t\tconst selectedEntries = entries.slice(0, remainingEntries);\n\t\tconst childEntries = selectedEntries.map((entry) => hashPath(root, entry, budget));\n\t\tif (selectedEntries.length < entries.length) {\n\t\t\tchildEntries.push({\n\t\t\t\tpath: normalized,\n\t\t\t\tstate: \"skipped-children\",\n\t\t\t\treason: \"entry-limit\",\n\t\t\t\tcount: entries.length - selectedEntries.length,\n\t\t\t});\n\t\t}\n\t\treturn { path: normalized, state: \"dir\", entries: childEntries };\n\t}\n\tif (stat.isFile()) return hashFileEntry(normalized, fullPath, stat, budget);\n\treturn { path: normalized, state: \"other\", mode: stat.mode };\n}\n\nfunction parsePorcelainZ(raw: string): Array<{ status: string; paths: string[] }> {\n\tconst tokens = raw.split(\"\\0\").filter(Boolean);\n\tconst entries: Array<{ status: string; paths: string[] }> = [];\n\tfor (let index = 0; index < tokens.length; index++) {\n\t\tconst token = tokens[index]!;\n\t\tif (token.length < 4) continue;\n\t\tconst status = token.slice(0, 2);\n\t\tconst relPath = token.slice(3);\n\t\tconst paths = [relPath];\n\t\tif (status[0] === \"R\" || status[0] === \"C\") {\n\t\t\tconst originalPath = tokens[++index];\n\t\t\tif (originalPath) paths.push(originalPath);\n\t\t}\n\t\tentries.push({ status, paths });\n\t}\n\treturn entries;\n}\n\nfunction buildRepoChangeSignature(root: string, statusOutput: string): WatchdogRepoChangeSignature {\n\tconst entries = parsePorcelainZ(statusOutput)\n\t\t.map((entry) => ({\n\t\t\tstatus: entry.status,\n\t\t\tpaths: entry.paths.map(normalizeRelPath).filter((relPath) => !ignoredRelPath(relPath)),\n\t\t}))\n\t\t.filter((entry) => entry.paths.length > 0)\n\t\t.sort((a, b) => `${a.status} ${a.paths.join(\"\\0\")}`.localeCompare(`${b.status} ${b.paths.join(\"\\0\")}`));\n\tconst changedPaths = [...new Set(entries.flatMap((entry) => entry.paths))].sort();\n\tconst budget: HashBudget = { entries: 0, bytes: 0, maxEntries: maxHashEntries(), maxBytes: maxHashTotalBytes() };\n\tconst payload = entries.map((entry) => ({\n\t\tstatus: entry.status,\n\t\tpaths: entry.paths,\n\t\tcontent: entry.paths.map((relPath) => hashPath(root, relPath, budget)),\n\t}));\n\tconst key = createHash(\"sha256\").update(JSON.stringify(payload)).digest(\"hex\");\n\treturn { root, key, changedPaths };\n}\n\nexport function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {\n\tconst root = git(cwd, [\"rev-parse\", \"--show-toplevel\"])?.trim();\n\tif (!root) return undefined;\n\tconst statusOutput = git(root, [\"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"]);\n\tif (statusOutput === undefined) return undefined;\n\ttry {\n\t\treturn buildRepoChangeSignature(root, statusOutput);\n\t} catch (error) {\n\t\tconsole.warn(\"[pi-subagents] watchdog repo change signature failed:\", (error as Error)?.message);\n\t\treturn undefined;\n\t}\n}\n\nfunction toolNameFromMessage(message: Record<string, unknown>): string {\n\tconst value = message.toolName ?? message.name;\n\treturn typeof value === \"string\" ? value : \"\";\n}\n\nfunction toolResultSucceeded(message: Record<string, unknown>): boolean {\n\treturn message.isError !== true && message.error === undefined;\n}\n\nfunction messageIndicatesRepoEdit(message: unknown): boolean {\n\tif (!message || typeof message !== \"object\") return false;\n\tconst input = message as Record<string, unknown>;\n\tconst role = input.role;\n\tif (role !== \"toolResult\" && role !== \"tool\") return false;\n\tconst toolName = toolNameFromMessage(input);\n\treturn (toolName === \"edit\" || toolName === \"write\") && toolResultSucceeded(input);\n}\n\nexport function eventIndicatesRepoEdit(event: unknown): boolean {\n\tif (!event || typeof event !== \"object\") return false;\n\tconst input = event as Record<string, unknown>;\n\tif (input.type === \"turn_end\" || input.event === \"turn_end\") {\n\t\treturn [input.message, ...(Array.isArray(input.toolResults) ? input.toolResults : [])].some(\n\t\t\tmessageIndicatesRepoEdit,\n\t\t);\n\t}\n\tif (input.type === \"tool_result\" || input.event === \"tool_result\")\n\t\treturn messageIndicatesRepoEdit({ role: \"toolResult\", ...input });\n\tif (input.type !== \"tool_result_end\" && input.event !== \"tool_result_end\") return false;\n\treturn messageIndicatesRepoEdit(input.message);\n}\n"]}