@staix/agent-mem 0.2.0 → 0.3.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/README.md +48 -1
- package/package.json +4 -2
- package/src/cli.ts +66 -4
- package/src/commands-table.ts +8 -0
- package/src/index.ts +4 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/commands.ts +6 -1
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +123 -14
- package/src/memory/sync.ts +22 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mutationDatabase } from "./mutation.ts";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
6
|
+
|
|
7
|
+
export interface BoundProfile {
|
|
8
|
+
kind: "shared-v1" | "generation";
|
|
9
|
+
/** Canonical direct memory directory, not the historical parent-home option. */
|
|
10
|
+
home: string;
|
|
11
|
+
dir: string;
|
|
12
|
+
generation?: string;
|
|
13
|
+
selectorRevision: string;
|
|
14
|
+
}
|
|
15
|
+
export type ProfileInput = "shared-v1" | { generation: string };
|
|
16
|
+
export interface ProfileSelector { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
|
|
17
|
+
export interface GenerationDescriptor { schema: "agent-mem.generation/v1"; generation: string; legacySchema: "agent-hub.memory/v1" }
|
|
18
|
+
export interface GenerationSeal { schema: "agent-mem.generation-seal/v1"; generation: string; descriptorHash: string; sourceDigest: string }
|
|
19
|
+
export const PROFILE_LOCK_TIMEOUT_MS = 3000;
|
|
20
|
+
export const LOCAL_METADATA_MAX_BYTES = 262_144;
|
|
21
|
+
export const validGeneration = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
22
|
+
export function canonicalJSON(value: unknown): string {
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`;
|
|
24
|
+
if (value && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJSON(item)}`).join(",")}}`;
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
export const hashJSON = (value: unknown): string => createHash("sha256").update(canonicalJSON(value)).digest("hex");
|
|
28
|
+
const unavailable = (): never => { throw new Error("memory profile metadata unavailable or invalid; inspect the local profile before continuing"); };
|
|
29
|
+
|
|
30
|
+
/** Fixed metadata paths are regular files; absent means absent, never unreadable or a symlink. */
|
|
31
|
+
export function readProfileJSON(path: string, limit = LOCAL_METADATA_MAX_BYTES): unknown | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const stat = lstatSync(path);
|
|
34
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) return unavailable();
|
|
35
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; return unavailable(); }
|
|
37
|
+
}
|
|
38
|
+
function keys(value: object, allowed: string[]): boolean { return Object.keys(value).every(key => allowed.includes(key)); }
|
|
39
|
+
export function readSelector(memoryDir: string): { selector?: ProfileSelector; revision: string } {
|
|
40
|
+
const value = readProfileJSON(join(memoryDir, "profile.json"));
|
|
41
|
+
if (value === undefined) return { revision: "absent" };
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !keys(value, ["schema", "kind", "generation"])) return unavailable();
|
|
43
|
+
const selector = value as ProfileSelector;
|
|
44
|
+
if (selector.schema !== "agent-mem.profile/v1" || !["shared-v1", "generation"].includes(selector.kind)
|
|
45
|
+
|| (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
|
|
46
|
+
return { selector, revision: hashJSON(selector) };
|
|
47
|
+
}
|
|
48
|
+
export const selectorRevision = (memoryDir: string): string => readSelector(memoryDir).revision;
|
|
49
|
+
export function generationDescriptor(generation: string): GenerationDescriptor {
|
|
50
|
+
if (!validGeneration(generation)) return unavailable();
|
|
51
|
+
return { schema: "agent-mem.generation/v1", generation, legacySchema: "agent-hub.memory/v1" };
|
|
52
|
+
}
|
|
53
|
+
export function generationDir(memoryDir: string, generation: string): string {
|
|
54
|
+
if (!validGeneration(generation)) return unavailable();
|
|
55
|
+
const generations = join(memoryDir, "generations"), dir = join(generations, generation);
|
|
56
|
+
for (const path of [generations, dir]) if (existsSync(path) && (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory())) return unavailable();
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
export function validateGeneration(memoryDir: string, generation: string): string {
|
|
60
|
+
const dir = generationDir(memoryDir, generation), expected = generationDescriptor(generation);
|
|
61
|
+
for (const folder of ["log", "local"]) {
|
|
62
|
+
const path = join(dir, folder);
|
|
63
|
+
if (!existsSync(path) || !lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) return unavailable();
|
|
64
|
+
}
|
|
65
|
+
if (canonicalJSON(readProfileJSON(join(dir, "generation.json"), 4096)) !== canonicalJSON(expected)) return unavailable();
|
|
66
|
+
const seal = readProfileJSON(join(dir, "generation-seal.json"), 4096) as GenerationSeal | undefined;
|
|
67
|
+
if (!seal || !keys(seal, ["schema", "generation", "descriptorHash", "sourceDigest"]) || seal.schema !== "agent-mem.generation-seal/v1" || seal.generation !== generation
|
|
68
|
+
|| seal.descriptorHash !== hashJSON(expected) || !/^[a-f0-9]{64}$/.test(seal.sourceDigest)) return unavailable();
|
|
69
|
+
const sources = readProfileJSON(join(dir, "migration-sources.json")) as { schema?: unknown; sources?: unknown; legacyCommit?: unknown } | undefined;
|
|
70
|
+
if (!sources || sources.schema !== "agent-mem.migration-sources/v1" || !Array.isArray(sources.sources) || seal.sourceDigest !== hashJSON({ sources: sources.sources, legacyCommit: sources.legacyCommit })) return unavailable();
|
|
71
|
+
const machine = readProfileJSON(join(dir, "machine.json"), 4096) as { id?: unknown; label?: unknown } | undefined;
|
|
72
|
+
if (!machine || typeof machine.id !== "string" || !/^[a-f0-9]{16}$/.test(machine.id) || typeof machine.label !== "string") return unavailable();
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
export function resolveProfile(memoryDir: string, explicit?: ProfileInput): BoundProfile {
|
|
76
|
+
const home = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), selected = readSelector(home);
|
|
77
|
+
if (explicit !== undefined && explicit !== "shared-v1" && (!explicit || typeof explicit !== "object" || !keys(explicit, ["generation"]) || !validGeneration(explicit.generation))) return unavailable();
|
|
78
|
+
const generation = explicit === "shared-v1" ? undefined : explicit?.generation ?? (selected.selector?.kind === "generation" ? selected.selector.generation : undefined);
|
|
79
|
+
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision }
|
|
80
|
+
: { kind: "shared-v1", home, dir: home, selectorRevision: selected.revision };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function fsyncDirectory(path: string): void { const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } }
|
|
84
|
+
/** Caller owns the home-wide profile lock for selector updates and generation fences for generation metadata. */
|
|
85
|
+
export function writeProfileJSON(path: string, value: unknown): void {
|
|
86
|
+
let serialized: string;
|
|
87
|
+
try {
|
|
88
|
+
serialized = canonicalJSON(value) + "\n";
|
|
89
|
+
JSON.parse(serialized);
|
|
90
|
+
if (Buffer.byteLength(serialized, "utf8") > LOCAL_METADATA_MAX_BYTES) return unavailable();
|
|
91
|
+
} catch { return unavailable(); }
|
|
92
|
+
// Match the reader's byte limit, including newline, before creating any temporary or replacing existing metadata.
|
|
93
|
+
const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
94
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
95
|
+
try { writeFileSync(fd, serialized); fsyncSync(fd); } finally { closeSync(fd); }
|
|
96
|
+
renameSync(temporary, path); fsyncDirectory(dirname(path));
|
|
97
|
+
}
|
|
98
|
+
export async function withProfileLock<T>(memoryDir: string, work: () => T | Promise<T>): Promise<T> {
|
|
99
|
+
const signature = processSignature(process.pid);
|
|
100
|
+
if (!signature) throw new Error("cannot establish profile lock ownership");
|
|
101
|
+
const file = join(memoryDir, "profile.lock"), owner = canonicalJSON({ pid: process.pid, signature, token: randomBytes(16).toString("hex") });
|
|
102
|
+
const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS;
|
|
103
|
+
for (;;) {
|
|
104
|
+
let acquired = false;
|
|
105
|
+
const fence = mutationDatabase(memoryDir);
|
|
106
|
+
let fenced = false;
|
|
107
|
+
try {
|
|
108
|
+
// Serialize the complete stale-owner probe/unlink/recreate sequence, then release before waiting on a live owner.
|
|
109
|
+
fence.run(`PRAGMA busy_timeout = ${Math.max(1, Math.min(25, deadline - Date.now()))}`);
|
|
110
|
+
try { fence.run("BEGIN IMMEDIATE"); fenced = true; } catch { /* bounded contention: retry after releasing connection */ }
|
|
111
|
+
if (fenced) for (let probe = 0; probe < 2; probe++) {
|
|
112
|
+
let fd: number;
|
|
113
|
+
try { fd = openSync(file, "wx", 0o600); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("profile lock unavailable");
|
|
116
|
+
try {
|
|
117
|
+
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) break;
|
|
118
|
+
const previous = readFileSync(file, "utf8"), record = JSON.parse(previous) as { pid?: unknown; signature?: unknown };
|
|
119
|
+
if (typeof record.signature === "string" && record.signature && processLiveness(record.pid, record.signature) === "gone" && readFileSync(file, "utf8") === previous) {
|
|
120
|
+
unlinkSync(file); continue;
|
|
121
|
+
}
|
|
122
|
+
} catch { /* live, malformed or unknown owner cannot be stolen */ }
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
try { writeFileSync(fd, owner); fsyncSync(fd); acquired = true; } finally { closeSync(fd); }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
if (fenced) fence.run("ROLLBACK");
|
|
130
|
+
fence.close();
|
|
131
|
+
}
|
|
132
|
+
if (!acquired) {
|
|
133
|
+
if (Date.now() >= deadline) throw new Error("profile lock held by a live or unknown owner");
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
try { return await work(); }
|
|
138
|
+
finally {
|
|
139
|
+
// A live owner's token cannot be reclaimed; do not remove a file another actor replaced.
|
|
140
|
+
if (existsSync(file) && !lstatSync(file).isSymbolicLink() && readFileSync(file, "utf8") === owner) { unlinkSync(file); fsyncDirectory(memoryDir); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { legacyEntries, legacyHead, readLegacySnapshot, type LegacyCheckpoint } from "./bridge.ts";
|
|
5
|
+
import { acquireMutation } from "./mutation.ts";
|
|
6
|
+
import { canonicalPath, realPath } from "./platform.ts";
|
|
7
|
+
import { canonicalJSON, fsyncDirectory, generationDescriptor, generationDir, hashJSON, readProfileJSON, readSelector, resolveProfile, validGeneration, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
8
|
+
|
|
9
|
+
export interface SourcePrefix extends LegacyCheckpoint { tailBytes: number }
|
|
10
|
+
interface MigrationJournal {
|
|
11
|
+
schema: "agent-mem.migration/v1";
|
|
12
|
+
generation: string;
|
|
13
|
+
selectorRevision: string;
|
|
14
|
+
phase: "captured" | "built" | "sealed" | "active";
|
|
15
|
+
sources: SourcePrefix[];
|
|
16
|
+
machine: { id: string; label: string };
|
|
17
|
+
legacyCommit?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface MigrationCounts {
|
|
20
|
+
validRows: number;
|
|
21
|
+
skippedRows: number;
|
|
22
|
+
byOp: Record<string, number>;
|
|
23
|
+
byTier: Record<string, number>;
|
|
24
|
+
tailBytes: number;
|
|
25
|
+
expiredRows: number;
|
|
26
|
+
eligibleRows: number;
|
|
27
|
+
}
|
|
28
|
+
export interface MigrationResult {
|
|
29
|
+
mode: MigrateOptions["mode"];
|
|
30
|
+
profile: BoundProfile;
|
|
31
|
+
counts?: MigrationCounts;
|
|
32
|
+
alreadyActive?: boolean;
|
|
33
|
+
consequence: string;
|
|
34
|
+
}
|
|
35
|
+
export type MigrationCheckpoint = "after-seal" | "before-selector" | "after-selector";
|
|
36
|
+
export interface MigrateOptions {
|
|
37
|
+
/** Direct M, with no historical parent-home conversion. */
|
|
38
|
+
memoryDir: string;
|
|
39
|
+
mode: "dry-run" | "apply" | "resume" | "deactivate";
|
|
40
|
+
generation?: string;
|
|
41
|
+
authorizedPerson: boolean;
|
|
42
|
+
tty: boolean;
|
|
43
|
+
/** Trusted builder must verify complete incremental/rebuild table equality. Injection is for crash fixtures. */
|
|
44
|
+
buildIndex?: (profile: BoundProfile, sources: readonly SourcePrefix[], legacyCommit?: string) => void | Promise<void>;
|
|
45
|
+
now?: () => number;
|
|
46
|
+
/** Trusted fault-injection callback for crash recovery fixtures; no protocol data or authority. */
|
|
47
|
+
checkpoint?: (stage: MigrationCheckpoint) => void | Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
const changed = (): never => { throw new Error("migration source or selector changed; inspect local migration state before continuing"); };
|
|
50
|
+
const consequence = "Generation writes never enter shared v1; legacy writers may continue. Deactivation omits generation-only notes and prune decisions.";
|
|
51
|
+
/** Capture only complete authored prefixes. This performs no writes, lock creation, index opens or network. */
|
|
52
|
+
export function readMigrationSources(memoryDir: string, generation?: string): SourcePrefix[] {
|
|
53
|
+
const commit = generation ? legacyHead(memoryDir, generation) : undefined;
|
|
54
|
+
return readLegacySnapshot(memoryDir, { generation, commit }).map(({ bytes: _bytes, local: _local, ...source }) => source);
|
|
55
|
+
}
|
|
56
|
+
export function readMigrationPrefix(memoryDir: string, source: SourcePrefix, generation?: string): Buffer {
|
|
57
|
+
return readLegacySnapshot(memoryDir, { generation, commit: source.commit, limits: [source] })[0]!.bytes;
|
|
58
|
+
}
|
|
59
|
+
function verifySources(memoryDir: string, generation: string, sources: readonly SourcePrefix[], legacyCommit?: string): void {
|
|
60
|
+
readLegacySnapshot(memoryDir, { generation, commit: legacyCommit, limits: sources });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function identity(memoryDir: string): { id: string; label: string } {
|
|
64
|
+
const value = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown; label?: unknown } | undefined;
|
|
65
|
+
if (!value || typeof value.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id) || typeof value.label !== "string") throw new Error("initialize a valid legacy machine identity before migration");
|
|
66
|
+
return { id: value.id, label: value.label };
|
|
67
|
+
}
|
|
68
|
+
function journalAt(memoryDir: string): string { return join(memoryDir, "migration.json"); }
|
|
69
|
+
function readJournal(memoryDir: string): MigrationJournal {
|
|
70
|
+
const value = readProfileJSON(journalAt(memoryDir)) as MigrationJournal | undefined;
|
|
71
|
+
if (!value || value.schema !== "agent-mem.migration/v1" || !validGeneration(value.generation) || !["captured", "built", "sealed", "active"].includes(value.phase)
|
|
72
|
+
|| !(value.selectorRevision === "absent" || /^[a-f0-9]{64}$/.test(value.selectorRevision)) || !Array.isArray(value.sources)
|
|
73
|
+
|| !value.machine || !/^[a-f0-9]{16}$/.test(value.machine.id) || typeof value.machine.label !== "string") throw new Error("no valid resumable migration journal");
|
|
74
|
+
for (const source of value.sources) if (!["legacy-local", "legacy-main"].includes(source.source) || typeof source.path !== "string" || !Number.isSafeInteger(source.offset) || source.offset < 0 || !/^[a-f0-9]{64}$/.test(source.hash)
|
|
75
|
+
|| !Number.isSafeInteger(source.tailBytes) || source.tailBytes < 0) throw new Error("invalid migration source receipt");
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
async function defaultBuild(profile: BoundProfile, sources: readonly SourcePrefix[], legacyCommit?: string): Promise<void> {
|
|
79
|
+
const core = await import("./store.ts");
|
|
80
|
+
await core.buildGenerationIndex(profile, sources, legacyCommit);
|
|
81
|
+
}
|
|
82
|
+
async function counts(memoryDir: string, generation: string | undefined, now: number, limits?: readonly SourcePrefix[], capturedCommit?: string): Promise<MigrationCounts> {
|
|
83
|
+
const core = await import("./store.ts");
|
|
84
|
+
let skippedRows = 0;
|
|
85
|
+
const commit = capturedCommit ?? (generation ? legacyHead(memoryDir, generation) : undefined);
|
|
86
|
+
const segments = readLegacySnapshot(memoryDir, { generation, commit, ...(limits ? { limits } : {}), onMalformed: () => skippedRows++ });
|
|
87
|
+
const rows = legacyEntries(segments, { onMalformed: () => {} });
|
|
88
|
+
const result = core.summarizeRawExpiry(rows.map(row => canonicalJSON(row)), now, segments.reduce((sum, source) => sum + source.tailBytes, 0));
|
|
89
|
+
return { ...result, skippedRows };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rememberJournal(memoryDir: string, journal: MigrationJournal): void {
|
|
93
|
+
writeProfileJSON(journalAt(memoryDir), journal);
|
|
94
|
+
}
|
|
95
|
+
function writeSources(memoryDir: string, journal: MigrationJournal): void {
|
|
96
|
+
writeProfileJSON(join(generationDir(memoryDir, journal.generation), "migration-sources.json"),
|
|
97
|
+
{ schema: "agent-mem.migration-sources/v1", sources: journal.sources, legacyCommit: journal.legacyCommit });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
|
|
101
|
+
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
|
|
102
|
+
if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
|
|
103
|
+
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
|
104
|
+
const selection = readSelector(memoryDir);
|
|
105
|
+
// Explicit undo reads only the selector: broken/missing G must not make the escape back to v1 impossible.
|
|
106
|
+
const selectedG = selection.selector?.kind === "generation" ? selection.selector.generation : undefined;
|
|
107
|
+
const initial: BoundProfile = options.mode === "deactivate"
|
|
108
|
+
? { kind: selectedG ? "generation" : "shared-v1", home: memoryDir, dir: selectedG ? join(memoryDir, "generations", selectedG) : memoryDir, generation: selectedG, selectorRevision: selection.revision }
|
|
109
|
+
: resolveProfile(memoryDir);
|
|
110
|
+
if (options.mode === "dry-run") {
|
|
111
|
+
return { mode: options.mode, profile: initial, counts: await counts(memoryDir, options.generation ?? initial.generation, (options.now ?? Date.now)()), consequence };
|
|
112
|
+
}
|
|
113
|
+
return withProfileLock(memoryDir, async () => {
|
|
114
|
+
const selected = readSelector(memoryDir);
|
|
115
|
+
if (selected.revision !== initial.selectorRevision) return changed();
|
|
116
|
+
if (options.mode === "deactivate") {
|
|
117
|
+
let releaseGeneration: (() => void) | undefined;
|
|
118
|
+
try {
|
|
119
|
+
if (initial.kind === "generation") {
|
|
120
|
+
// Use an existing safe fence when available; never create, repair or validate corrupt generation state.
|
|
121
|
+
const parent = join(memoryDir, "generations"), file = join(initial.dir, "mutation-lock.db");
|
|
122
|
+
const safeDirectory = (path: string) => existsSync(path) && lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink();
|
|
123
|
+
if (safeDirectory(parent) && safeDirectory(initial.dir) && existsSync(file) && lstatSync(file).isFile() && !lstatSync(file).isSymbolicLink()) {
|
|
124
|
+
let unavailable = false;
|
|
125
|
+
try { releaseGeneration = acquireMutation(initial.dir); } catch { unavailable = true; }
|
|
126
|
+
if (!unavailable && !releaseGeneration) throw new Error("generation mutation lock is busy");
|
|
127
|
+
}
|
|
128
|
+
if (readSelector(memoryDir).revision !== initial.selectorRevision) return changed();
|
|
129
|
+
writeProfileJSON(join(memoryDir, "profile.json"), { schema: "agent-mem.profile/v1", kind: "shared-v1" });
|
|
130
|
+
}
|
|
131
|
+
return { mode: options.mode, profile: resolveProfile(memoryDir), consequence };
|
|
132
|
+
} finally { releaseGeneration?.(); }
|
|
133
|
+
}
|
|
134
|
+
// Migration apply/resume never initialize an absent or invalid legacy identity.
|
|
135
|
+
const machine = identity(memoryDir);
|
|
136
|
+
let journal: MigrationJournal;
|
|
137
|
+
if (options.mode === "resume") {
|
|
138
|
+
journal = readJournal(memoryDir);
|
|
139
|
+
if (options.generation !== undefined && options.generation !== journal.generation) return changed();
|
|
140
|
+
} else {
|
|
141
|
+
if (initial.kind === "generation") {
|
|
142
|
+
if (options.generation !== undefined && options.generation !== initial.generation) throw new Error("deactivate the current generation before selecting another");
|
|
143
|
+
return { mode: options.mode, profile: initial, alreadyActive: true, counts: await counts(memoryDir, initial.generation, (options.now ?? Date.now)()), consequence };
|
|
144
|
+
}
|
|
145
|
+
if (existsSync(journalAt(memoryDir))) {
|
|
146
|
+
const previous = readJournal(memoryDir);
|
|
147
|
+
if (previous.phase !== "active") throw new Error("unfinished migration requires explicit resume");
|
|
148
|
+
}
|
|
149
|
+
const generation = options.generation ?? randomBytes(16).toString("hex");
|
|
150
|
+
const dir = generationDir(memoryDir, generation);
|
|
151
|
+
let generationMachine = machine;
|
|
152
|
+
if (existsSync(dir)) {
|
|
153
|
+
// Reopening a sealed, same-machine generation is explicit selection, not recovery of an unknown staging dir.
|
|
154
|
+
resolveProfile(memoryDir, { generation });
|
|
155
|
+
const existing = readProfileJSON(join(dir, "machine.json")) as { id?: unknown; label?: unknown } | undefined;
|
|
156
|
+
if (!existing || existing.id !== machine.id || typeof existing.label !== "string") return changed();
|
|
157
|
+
generationMachine = { id: machine.id, label: existing.label };
|
|
158
|
+
}
|
|
159
|
+
const releaseLegacy = acquireMutation(memoryDir);
|
|
160
|
+
if (!releaseLegacy) throw new Error("legacy mutation lock is busy");
|
|
161
|
+
let sources: SourcePrefix[], legacyCommit: string | undefined;
|
|
162
|
+
try {
|
|
163
|
+
legacyCommit = existsSync(dir) ? legacyHead(memoryDir, generation) : undefined;
|
|
164
|
+
const previous = existsSync(dir) ? readProfileJSON(join(dir, "migration-sources.json")) as { sources?: SourcePrefix[]; legacyCommit?: string } | undefined : undefined;
|
|
165
|
+
const segments = readLegacySnapshot(memoryDir, { generation, commit: legacyCommit, previousCommit: previous?.legacyCommit, checkpoints: previous?.sources });
|
|
166
|
+
sources = segments.map(({ bytes: _bytes, local: _local, ...source }) => source);
|
|
167
|
+
} finally { releaseLegacy(); }
|
|
168
|
+
journal = { schema: "agent-mem.migration/v1", generation, selectorRevision: initial.selectorRevision, phase: "captured", sources, machine: generationMachine, legacyCommit };
|
|
169
|
+
// Persist G before creating any staging state, so crash recovery cannot invent a second generation.
|
|
170
|
+
rememberJournal(memoryDir, journal);
|
|
171
|
+
}
|
|
172
|
+
const dir = generationDir(memoryDir, journal.generation);
|
|
173
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
174
|
+
for (const folder of ["log", "local"]) mkdirSync(join(dir, folder), { recursive: true, mode: 0o700 });
|
|
175
|
+
for (const [name, value] of [["generation.json", generationDescriptor(journal.generation)], ["machine.json", journal.machine]] as const) {
|
|
176
|
+
const prior = readProfileJSON(join(dir, name));
|
|
177
|
+
if (prior === undefined) writeProfileJSON(join(dir, name), value);
|
|
178
|
+
else if (canonicalJSON(prior) !== canonicalJSON(value)) return changed();
|
|
179
|
+
}
|
|
180
|
+
rememberJournal(memoryDir, journal);
|
|
181
|
+
fsyncDirectory(join(memoryDir, "generations")); fsyncDirectory(memoryDir);
|
|
182
|
+
if (selected.selector?.kind === "generation" && selected.selector.generation === journal.generation) {
|
|
183
|
+
const profile = resolveProfile(memoryDir);
|
|
184
|
+
journal.phase = "active"; rememberJournal(memoryDir, journal);
|
|
185
|
+
return { mode: options.mode, profile, alreadyActive: true, counts: await counts(memoryDir, journal.generation, (options.now ?? Date.now)(), journal.sources, journal.legacyCommit), consequence };
|
|
186
|
+
}
|
|
187
|
+
if (selected.revision !== journal.selectorRevision) return changed();
|
|
188
|
+
if (canonicalJSON(readProfileJSON(join(dir, "generation.json"))) !== canonicalJSON(generationDescriptor(journal.generation))
|
|
189
|
+
|| canonicalJSON(readProfileJSON(join(dir, "machine.json"))) !== canonicalJSON(journal.machine) || journal.machine.id !== machine.id) return changed();
|
|
190
|
+
verifySources(memoryDir, journal.generation, journal.sources, journal.legacyCommit);
|
|
191
|
+
const profile: BoundProfile = { kind: "generation", home: memoryDir, dir, generation: journal.generation, selectorRevision: journal.selectorRevision };
|
|
192
|
+
const releaseGeneration = acquireMutation(dir);
|
|
193
|
+
if (!releaseGeneration) throw new Error("generation mutation lock is busy");
|
|
194
|
+
try {
|
|
195
|
+
if (journal.phase !== "sealed" && journal.phase !== "active") writeSources(memoryDir, journal);
|
|
196
|
+
if (journal.phase !== "sealed" && journal.phase !== "active") {
|
|
197
|
+
await (options.buildIndex ?? defaultBuild)(profile, journal.sources, journal.legacyCommit);
|
|
198
|
+
journal.phase = "built"; rememberJournal(memoryDir, journal);
|
|
199
|
+
verifySources(memoryDir, journal.generation, journal.sources, journal.legacyCommit);
|
|
200
|
+
writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation: journal.generation,
|
|
201
|
+
descriptorHash: hashJSON(generationDescriptor(journal.generation)), sourceDigest: hashJSON({ sources: journal.sources, legacyCommit: journal.legacyCommit }) });
|
|
202
|
+
journal.phase = "sealed"; rememberJournal(memoryDir, journal);
|
|
203
|
+
if (options.checkpoint) await options.checkpoint("after-seal");
|
|
204
|
+
}
|
|
205
|
+
verifySources(memoryDir, journal.generation, journal.sources, journal.legacyCommit);
|
|
206
|
+
resolveProfile(memoryDir, { generation: journal.generation });
|
|
207
|
+
if (options.checkpoint) await options.checkpoint("before-selector");
|
|
208
|
+
if (readSelector(memoryDir).revision !== journal.selectorRevision) return changed();
|
|
209
|
+
verifySources(memoryDir, journal.generation, journal.sources, journal.legacyCommit);
|
|
210
|
+
writeProfileJSON(join(memoryDir, "profile.json"), { schema: "agent-mem.profile/v1", kind: "generation", generation: journal.generation });
|
|
211
|
+
if (options.checkpoint) await options.checkpoint("after-selector");
|
|
212
|
+
journal.phase = "active"; rememberJournal(memoryDir, journal);
|
|
213
|
+
return { mode: options.mode, profile: resolveProfile(memoryDir), counts: await counts(memoryDir, journal.generation, (options.now ?? Date.now)(), journal.sources, journal.legacyCommit), consequence };
|
|
214
|
+
} finally { releaseGeneration(); }
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Entry } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
export interface PayloadField {
|
|
4
|
+
type: "string" | "string-array";
|
|
5
|
+
values?: readonly string[];
|
|
6
|
+
format?: "utc" | "id";
|
|
7
|
+
maxLength?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PayloadSchema {
|
|
10
|
+
version: 1;
|
|
11
|
+
required: readonly string[];
|
|
12
|
+
fields: Readonly<Record<string, PayloadField>>;
|
|
13
|
+
/** Reader tolerance retained for pre-existing v1 logs, not permission to omit writer fields. */
|
|
14
|
+
legacyOptional: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export interface OpContract {
|
|
17
|
+
status: "v1-equal" | "truth-changing";
|
|
18
|
+
implemented: boolean;
|
|
19
|
+
payloadVersion: string;
|
|
20
|
+
required: readonly string[];
|
|
21
|
+
payloadSchema: PayloadSchema | null;
|
|
22
|
+
immutableTargets: string;
|
|
23
|
+
placement: string;
|
|
24
|
+
precedence: string;
|
|
25
|
+
identity: string;
|
|
26
|
+
crashBoundary: string;
|
|
27
|
+
fixtures: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const text: PayloadField = { type: "string" };
|
|
31
|
+
const id: PayloadField = { type: "string", format: "id" };
|
|
32
|
+
const utc: PayloadField = { type: "string", format: "utc" };
|
|
33
|
+
const strings: PayloadField = { type: "string-array" };
|
|
34
|
+
const base: Record<string, PayloadField> = {
|
|
35
|
+
schema: { type: "string", values: ["agent-hub.memory/v1"] }, op: text,
|
|
36
|
+
id, at: utc, machine: { type: "string", maxLength: 64 },
|
|
37
|
+
};
|
|
38
|
+
const identity = "sha256(canonical complete payload without id), first 32 hex; schema, at and machine participate; replay INSERT OR IGNORE";
|
|
39
|
+
const crash = "complete newline is authoritative; append precedes derived-index refresh; incomplete tail waits; refresh/rebuild recovers a complete append";
|
|
40
|
+
function current(op: Entry["op"], fields: Record<string, PayloadField>, required: string[], placement: string, targets: string, precedence: string, legacyOptional: string[] = []): OpContract {
|
|
41
|
+
const keys = [...Object.keys(base), ...required];
|
|
42
|
+
return Object.freeze<OpContract>({
|
|
43
|
+
status: "v1-equal", implemented: true, payloadVersion: "agent-hub.memory/v1",
|
|
44
|
+
required: keys, payloadSchema: { version: 1, required: keys, fields: { ...base, op: { type: "string", values: [op] }, ...fields }, legacyOptional },
|
|
45
|
+
immutableTargets: targets, placement, precedence, identity, crashBoundary: crash,
|
|
46
|
+
fixtures: [`v1-${op}`],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function ordinaryOp(op: Entry["op"], placement: string, targets: string, precedence: string, dated = false): OpContract {
|
|
50
|
+
const hasBy = !["forget", "confirm", "reject"].includes(op);
|
|
51
|
+
const by = dated ? utc : op === "supersede" ? id : op === "goal_state"
|
|
52
|
+
? { type: "string" as const, values: ["open", "active", "blocked", "done", "dropped"] } : text;
|
|
53
|
+
return current(op, { target: op === "settle" ? { ...text, maxLength: 400 } : id, actor: text, ...(hasBy ? { by } : {}) },
|
|
54
|
+
["target", "actor", ...(hasBy ? ["by"] : [])], placement, targets, precedence, ["actor"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Exhaustive over the current writer's Entry union: adding an op requires a contract in this registry. */
|
|
58
|
+
const v1 = {
|
|
59
|
+
note: current("note", {
|
|
60
|
+
project: { type: "string", maxLength: 400 }, scope: { type: "string", values: ["project", "shared"] },
|
|
61
|
+
tier: { type: "string", values: ["short", "long"] }, kind: { type: "string", values: ["decision", "finding", "contract", "fail"] },
|
|
62
|
+
peer: { type: "string", maxLength: 64 }, title: text, text, task: text, about: strings,
|
|
63
|
+
supersedes: id, promotedFrom: id, source: { type: "string", values: ["hub", "person", "claude-mem"] }, expires: utc,
|
|
64
|
+
}, ["project", "scope", "tier", "kind", "peer", "text", "about", "source"], "short -> local; long -> synced",
|
|
65
|
+
"optional supersedes and promotedFrom are full immutable entry ids; about nodes and task keys retain v1 identity",
|
|
66
|
+
"newest at/id first; note supersedes joins supersede ops; promoted source hidden; expiry trusts wall clock (declared C05 rollback ceiling)", ["source"]),
|
|
67
|
+
forget: ordinaryOp("forget", "follows target authored segment: local when target.local, synced otherwise", "target entry id, promotion ancestry included", "forget wins independent of timestamp; never edits source rows"),
|
|
68
|
+
supersede: ordinaryOp("supersede", "follows old target authored segment: local when old.local, synced otherwise; replacement visibility never narrower", "target old id; by replacement id, never a mutable alias", "latest relation by at then replacement id; earliest cycle relation yields; gone replacement releases old entry"),
|
|
69
|
+
demote: ordinaryOp("demote", "synced for long targets; local for local short targets", "target entry id; by fixed expiry", "latest demotion at/id fixes short deadline; clock rollback retains C05 ceiling", true),
|
|
70
|
+
settle: ordinaryOp("settle", "local", "target immutable project#task@created key; by fixed expiry", "earliest settlement deadline wins; eligible held notes wait for promotion; clock rollback retains C05 ceiling", true),
|
|
71
|
+
goal: current("goal", {
|
|
72
|
+
project: { type: "string", maxLength: 400 }, scope: { type: "string", values: ["project", "shared"] },
|
|
73
|
+
title: { type: "string", maxLength: 400 }, milestones: strings, dependsOn: strings, actor: text,
|
|
74
|
+
}, ["project", "scope", "title", "milestones", "dependsOn", "actor"], "synced", "dependsOn goal ids; goal's full id is its immutable identity",
|
|
75
|
+
"goals newest at first; initial state open; subsequent state/link/lease ops reduce separately", ["scope", "actor"]),
|
|
76
|
+
goal_state: ordinaryOp("goal_state", "synced", "target full goal id; by open|active|blocked|done|dropped", "latest valid state by at/id wins"),
|
|
77
|
+
goal_link: ordinaryOp("goal_link", "synced", "target full goal id; by immutable project#task@created key", "unique authored intent; links retain at/machine; identical payload replays once"),
|
|
78
|
+
lease: ordinaryOp("lease", "synced", "target full goal id; by fixed UTC expiry; machine is holder identity", "claims after last forced claim; latest per machine; earliest uninterrupted active claim then machine id wins; expired holder is metadata", true),
|
|
79
|
+
lease_force: ordinaryOp("lease_force", "synced", "target full goal id; by fixed UTC expiry", "latest forced claim at/id discards earlier claims, then ordinary lease ordering applies", true),
|
|
80
|
+
suggest: ordinaryOp("suggest", "synced", "target source entry id; by relation:destination id", "suggestions stay dashed, never confirmed graph evidence; suggestion full id is verdict target"),
|
|
81
|
+
confirm: ordinaryOp("confirm", "synced", "target immutable suggestion id", "latest confirm/reject by at/id wins; confirmation adds evidence relation only for valid endpoints"),
|
|
82
|
+
reject: ordinaryOp("reject", "synced", "target immutable suggestion id", "latest confirm/reject by at/id wins; rejection excludes suggested relation from evidence"),
|
|
83
|
+
} satisfies Record<Entry["op"], OpContract>;
|
|
84
|
+
|
|
85
|
+
function blocked(op: string): OpContract {
|
|
86
|
+
return Object.freeze({ status: "truth-changing", implemented: false, payloadVersion: "unimplemented", required: [], payloadSchema: null,
|
|
87
|
+
immutableTargets: "not defined: no writer", placement: "future generation only; never local or synced shared-v1 segments",
|
|
88
|
+
precedence: "not defined: person-run generation migration and contracts required first", identity: "not defined: no payload is emitted",
|
|
89
|
+
crashBoundary: "refused before authoritative append", fixtures: [`deny-${op}`] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const V1_OPS: readonly Entry["op"][] = Object.freeze(Object.keys(v1) as Entry["op"][]);
|
|
93
|
+
export const OP_CONTRACTS: Readonly<Record<string, OpContract>> = Object.freeze({ ...v1,
|
|
94
|
+
prune: blocked("prune"), correct: blocked("correct"), lift: blocked("lift"), consolidate: blocked("consolidate"), group: blocked("group"),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** No reader inventory or announcement can authorize new truth in shared v1 logs. */
|
|
98
|
+
export function assertSharedV1Op(op: unknown): void {
|
|
99
|
+
const contract = typeof op === "string" && Object.hasOwn(OP_CONTRACTS, op) ? OP_CONTRACTS[op] : undefined;
|
|
100
|
+
if (!contract?.implemented || contract.status !== "v1-equal") throw new Error("operation is not allowed in shared v1 segments; a person-run generation migration and contract are required");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Separate registry: importing this metadata never authorizes prune in OP_CONTRACTS/shared v1. */
|
|
104
|
+
export interface GenerationPayloadField {
|
|
105
|
+
type: "string" | "object" | "array";
|
|
106
|
+
values?: readonly string[];
|
|
107
|
+
pattern?: string;
|
|
108
|
+
format?: "canonical-utc";
|
|
109
|
+
required?: readonly string[];
|
|
110
|
+
fields?: Readonly<Record<string, GenerationPayloadField>>;
|
|
111
|
+
additionalProperties?: false;
|
|
112
|
+
items?: GenerationPayloadField;
|
|
113
|
+
minItems?: number;
|
|
114
|
+
maxItems?: number;
|
|
115
|
+
sortedUniqueBy?: string;
|
|
116
|
+
}
|
|
117
|
+
export interface GenerationOpContract extends Omit<OpContract, "implemented" | "payloadSchema"> {
|
|
118
|
+
implemented: true;
|
|
119
|
+
generationOnly: true;
|
|
120
|
+
targetSemantics: "exact-entry-v1";
|
|
121
|
+
validator: "validatePruneRow";
|
|
122
|
+
validation: readonly string[];
|
|
123
|
+
payloadSchema: {
|
|
124
|
+
version: 1;
|
|
125
|
+
required: readonly string[];
|
|
126
|
+
fields: Readonly<Record<string, GenerationPayloadField>>;
|
|
127
|
+
additionalProperties: false;
|
|
128
|
+
maxUtf8Bytes: number;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const generationId: GenerationPayloadField = { type: "string", pattern: "^[a-f0-9]{32}$" };
|
|
132
|
+
const authoredHash: GenerationPayloadField = { type: "string", pattern: "^[a-f0-9]{64}$" };
|
|
133
|
+
const canonicalUtc: GenerationPayloadField = { type: "string", format: "canonical-utc" };
|
|
134
|
+
const pruneRequired = ["schema", "op", "generation", "at", "machine", "actor", "placement", "cutoff", "targets", "id"] as const;
|
|
135
|
+
export const PRUNE_GENERATION_CONTRACT: GenerationOpContract = Object.freeze<GenerationOpContract>({
|
|
136
|
+
status: "truth-changing", implemented: true, generationOnly: true, targetSemantics: "exact-entry-v1", validator: "validatePruneRow",
|
|
137
|
+
validation: ["exact required keys at each nested level; no additional fields", "canonical UTC; cutoff <= at; each expiredAt <= cutoff", "1..32 targets, strictly sorted unique full ids; canonical row <=16384 UTF-8 bytes", "id equals first 32 hex of canonical complete payload hash excluding id", "generation and authored segment placement match the bound generation/input", "target/basis full authored hashes and explicit expires/by relationship verified separately by reducer; missing witnesses remain pending"],
|
|
138
|
+
payloadVersion: "agent-mem.prune/v1", required: pruneRequired,
|
|
139
|
+
payloadSchema: { version: 1, required: pruneRequired, additionalProperties: false, maxUtf8Bytes: 16_384, fields: {
|
|
140
|
+
schema: { type: "string", values: ["agent-mem.prune/v1"] }, op: { type: "string", values: ["prune"] },
|
|
141
|
+
generation: generationId, at: canonicalUtc, machine: { type: "string", pattern: "^[a-f0-9]{16}$" },
|
|
142
|
+
actor: { type: "string", values: ["person"] }, placement: { type: "string", values: ["local", "synced"] }, cutoff: canonicalUtc, id: generationId,
|
|
143
|
+
targets: { type: "array", minItems: 1, maxItems: 32, sortedUniqueBy: "id", items: {
|
|
144
|
+
type: "object", required: ["id", "rowHash", "expiredAt", "basis"], additionalProperties: false, fields: {
|
|
145
|
+
id: generationId, rowHash: authoredHash, expiredAt: canonicalUtc,
|
|
146
|
+
basis: { type: "object", required: ["kind", "id", "rowHash"], additionalProperties: false, fields: {
|
|
147
|
+
kind: { type: "string", values: ["note", "demote", "settle"] }, id: generationId, rowHash: authoredHash,
|
|
148
|
+
} },
|
|
149
|
+
},
|
|
150
|
+
} },
|
|
151
|
+
} },
|
|
152
|
+
immutableTargets: "only explicit full note id plus SHA-256 of complete canonical authored row including id; exact note.expires or demote/settle.by basis id/hash; no implicit TTL/backstop proof or ancestry traversal",
|
|
153
|
+
placement: "generation local by default; synced only for positively attested public target and basis already in verified cached legacy-main or generation public segments; no policy-free bypass",
|
|
154
|
+
precedence: "validated exact target is permanently gone, union/idempotent across prune rows; later clock/deadline changes do not revoke it; missing witnesses pending; different IDs and independent replacements survive; raw promotion metadata remains for v1 source hiding",
|
|
155
|
+
identity: "sha256(canonical complete prune payload excluding id), first 32 hex; generation/at/machine/placement/cutoff/ordered targets participate; rowHash is full SHA-256 of authored witness including id",
|
|
156
|
+
crashBoundary: "fsync exact local sealed plan, mutation fence, revalidate proofs/policy, append+fsync complete row then refresh; per-row atomicity; retry skips durable IDs; incomplete own tail completes only from identical persisted-plan prefix; mismatched tail refuses",
|
|
157
|
+
fixtures: ["test/generation-prune.test.ts#only explicit note expires or demote settle by values can prove expiry", "test/generation-prune.test.ts#exact-entry reducer waits for witnesses and cannot be reversed by clock or later deadlines", "test/generation-prune.test.ts#prune partitions private target or basis locally and public attestations bind only public rows", "test/generation-prune.test.ts#prune row shape hashes canonical time ordering placement and byte bounds are strict"],
|
|
158
|
+
});
|
|
159
|
+
export const GENERATION_OP_CONTRACTS: Readonly<Record<"prune", GenerationOpContract>> = Object.freeze({ prune: PRUNE_GENERATION_CONTRACT });
|
package/src/memory/platform.ts
CHANGED
|
@@ -82,7 +82,14 @@ export const memoryGitArgv = (args: string[], opts: { credentials?: boolean } =
|
|
|
82
82
|
["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
|
|
83
83
|
|
|
84
84
|
/** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
|
|
85
|
-
export
|
|
85
|
+
export function memoryGitEnv(env: NodeJS.ProcessEnv = process.env, options: { repositoryIsolation?: boolean } = {}): NodeJS.ProcessEnv {
|
|
86
|
+
const result: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: "0" };
|
|
87
|
+
if (options.repositoryIsolation) {
|
|
88
|
+
for (const key of Object.keys(result)) if (["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_GRAFT_FILE", "GIT_REPLACE_REF_BASE", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"].includes(key) || /^GIT_CONFIG_(KEY|VALUE)_/.test(key)) delete result[key];
|
|
89
|
+
result.GIT_NO_REPLACE_OBJECTS = "1";
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
86
93
|
|
|
87
94
|
/** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
|
|
88
95
|
export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|