@nseng-ai/plans 0.1.1
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/package.json +29 -0
- package/src/api.ts +25 -0
- package/src/cli.ts +430 -0
- package/src/content-slug-derivation.ts +127 -0
- package/src/index.ts +72 -0
- package/src/plan-persistence.ts +149 -0
- package/src/plan-store-gateway.ts +104 -0
- package/src/saved-plan-content-slug.ts +31 -0
- package/src/saved-plan-file.ts +583 -0
- package/src/saved-plan-selection.ts +361 -0
- package/src/testing.ts +129 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { basename, isAbsolute } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildPlanFileName,
|
|
6
|
+
buildPlanStoreBranchDirectoryPath,
|
|
7
|
+
encodeBranchForPlanPath,
|
|
8
|
+
findLatestSavedPlanFile,
|
|
9
|
+
resolvePlanStoreDirectory,
|
|
10
|
+
type LatestSavedPlanFileEvidence,
|
|
11
|
+
type PlanStoreDirectoryEvidence,
|
|
12
|
+
type SavedPlanFileEvidence,
|
|
13
|
+
type PlanStoreOptions,
|
|
14
|
+
} from "./saved-plan-file.ts";
|
|
15
|
+
import { createRealPlanStoreGateway, type PlanStoreGateway } from "./plan-store-gateway.ts";
|
|
16
|
+
import type { CommandExecApi } from "@nseng-ai/foundation/exec";
|
|
17
|
+
import { isPathInside, normalizePlanFilePath, validatePlanSlug } from "./plan-persistence.ts";
|
|
18
|
+
|
|
19
|
+
export const WRITE_SAVED_PLAN_FILE_TOOL_NAME = "write_saved_plan_file";
|
|
20
|
+
|
|
21
|
+
export type ValidatedSessionSavedPlan = LatestSavedPlanFileEvidence & {
|
|
22
|
+
summary?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SessionSavedPlanValidation =
|
|
26
|
+
| { type: "valid"; plan: ValidatedSessionSavedPlan }
|
|
27
|
+
| { type: "stale"; reason: string }
|
|
28
|
+
| { type: "unsafe"; message: string };
|
|
29
|
+
|
|
30
|
+
export type LatestSessionSavedPlanResult =
|
|
31
|
+
| { type: "found"; plan: ValidatedSessionSavedPlan }
|
|
32
|
+
| { type: "not-found" }
|
|
33
|
+
| { type: "unsafe"; message: string };
|
|
34
|
+
|
|
35
|
+
export type SelectedSavedPlanFile =
|
|
36
|
+
| {
|
|
37
|
+
type: "explicit";
|
|
38
|
+
filePath: string;
|
|
39
|
+
fileName: string;
|
|
40
|
+
savedPlanFileStem: string;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
type: "session";
|
|
44
|
+
plan: ValidatedSessionSavedPlan;
|
|
45
|
+
savedPlanFileStem: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
type: "latest";
|
|
49
|
+
plan: LatestSavedPlanFileEvidence;
|
|
50
|
+
savedPlanFileStem: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface ResolveSelectedSavedPlanFileOptions extends PlanStoreOptions {
|
|
54
|
+
explicitPath?: string;
|
|
55
|
+
sessionEntries?: readonly unknown[];
|
|
56
|
+
shouldFallbackToLatest?: boolean;
|
|
57
|
+
shouldAllowSessionSourceBranchMismatch?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ValidateSessionSavedPlanCandidateOptions {
|
|
61
|
+
shouldAllowSourceBranchMismatch?: boolean;
|
|
62
|
+
planStoreGateway?: PlanStoreGateway;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const repoIdentitySourceSchema = z.enum(["origin-url", "repo-root"]);
|
|
66
|
+
|
|
67
|
+
const savedPlanFileEvidenceSchema = z.object({
|
|
68
|
+
slug: z.string(),
|
|
69
|
+
repoRoot: z.string(),
|
|
70
|
+
repoKey: z.string(),
|
|
71
|
+
repoIdentitySource: repoIdentitySourceSchema,
|
|
72
|
+
sourceBranch: z.string(),
|
|
73
|
+
branchKey: z.string(),
|
|
74
|
+
filePath: z.string(),
|
|
75
|
+
summary: z.string().optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const savedPlanFileSessionEntrySchema = z.object({
|
|
79
|
+
type: z.literal("message"),
|
|
80
|
+
message: z
|
|
81
|
+
.object({
|
|
82
|
+
role: z.literal("toolResult"),
|
|
83
|
+
toolName: z.literal(WRITE_SAVED_PLAN_FILE_TOOL_NAME),
|
|
84
|
+
isError: z.unknown().optional(),
|
|
85
|
+
details: savedPlanFileEvidenceSchema,
|
|
86
|
+
})
|
|
87
|
+
.refine((message) => message.isError !== true),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function extractSavedPlanFileEvidenceFromSessionEntry(
|
|
91
|
+
entry: unknown,
|
|
92
|
+
): SavedPlanFileEvidence | undefined {
|
|
93
|
+
const result = savedPlanFileSessionEntrySchema.safeParse(entry);
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return toSavedPlanFileEvidence(result.data.message.details);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toSavedPlanFileEvidence(
|
|
102
|
+
data: z.infer<typeof savedPlanFileEvidenceSchema>,
|
|
103
|
+
): SavedPlanFileEvidence {
|
|
104
|
+
const { summary, ...evidence } = data;
|
|
105
|
+
return { ...evidence, ...(summary === undefined ? {} : { summary }) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function validateSessionSavedPlanCandidate(
|
|
109
|
+
evidence: SavedPlanFileEvidence,
|
|
110
|
+
directory: PlanStoreDirectoryEvidence,
|
|
111
|
+
options: ValidateSessionSavedPlanCandidateOptions = {},
|
|
112
|
+
): Promise<SessionSavedPlanValidation> {
|
|
113
|
+
if (!isAbsolute(evidence.filePath)) {
|
|
114
|
+
return unsafe(
|
|
115
|
+
`Session saved-plan evidence file path must be absolute: ${evidence.filePath || "(empty)"}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!evidence.filePath.endsWith(".md")) {
|
|
119
|
+
return unsafe(
|
|
120
|
+
`Session saved-plan evidence file path must use a .md filename: ${evidence.filePath}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const slugError = validatePlanSlug(evidence.slug);
|
|
125
|
+
if (slugError !== undefined) {
|
|
126
|
+
return unsafe(
|
|
127
|
+
`Session saved-plan evidence has an invalid slug ${JSON.stringify(evidence.slug)}: ${slugError}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fileName = basename(evidence.filePath);
|
|
132
|
+
const expectedFileName = buildPlanFileName(evidence.slug);
|
|
133
|
+
if (fileName !== expectedFileName) {
|
|
134
|
+
return unsafe(
|
|
135
|
+
`Session saved-plan evidence basename must match slug: expected ${expectedFileName}, got ${fileName || "(empty)"}.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const planStoreGateway = options.planStoreGateway ?? createRealPlanStoreGateway();
|
|
140
|
+
const metadata = validateDirectoryMetadata(evidence, directory, options);
|
|
141
|
+
if (metadata.type === "invalid") {
|
|
142
|
+
return unsafe(metadata.message);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const expectedDirectoryPath = metadata.directoryPath;
|
|
146
|
+
if (!isPathInside(expectedDirectoryPath, evidence.filePath)) {
|
|
147
|
+
return unsafe(
|
|
148
|
+
[
|
|
149
|
+
formatOutsidePlanStoreDirectoryMessage(expectedDirectoryPath, directory.directoryPath),
|
|
150
|
+
`Plan store directory: ${expectedDirectoryPath}`,
|
|
151
|
+
`Saved plan path: ${evidence.filePath}`,
|
|
152
|
+
].join("\n"),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fileStat = await planStoreGateway.statPath(evidence.filePath);
|
|
157
|
+
if (fileStat === undefined) {
|
|
158
|
+
return {
|
|
159
|
+
type: "stale",
|
|
160
|
+
reason: `Saved plan file no longer exists or is not accessible: ${evidence.filePath}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (fileStat.type !== "file") {
|
|
164
|
+
return unsafe(`Session saved-plan evidence path is not a regular file: ${evidence.filePath}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const realDirectoryPath = await planStoreGateway.realpathOrResolve(expectedDirectoryPath);
|
|
168
|
+
const realFilePath = await planStoreGateway.realpathOrResolve(evidence.filePath);
|
|
169
|
+
if (!isPathInside(realDirectoryPath, realFilePath)) {
|
|
170
|
+
return unsafe(
|
|
171
|
+
[
|
|
172
|
+
"Session saved-plan evidence resolves outside the current local plan store directory.",
|
|
173
|
+
`Plan store directory: ${directory.directoryPath}`,
|
|
174
|
+
`Resolved plan store directory: ${realDirectoryPath}`,
|
|
175
|
+
`Saved plan path: ${evidence.filePath}`,
|
|
176
|
+
`Resolved saved plan path: ${realFilePath}`,
|
|
177
|
+
].join("\n"),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const plan: ValidatedSessionSavedPlan = {
|
|
182
|
+
...directory,
|
|
183
|
+
sourceBranch: evidence.sourceBranch,
|
|
184
|
+
branchKey: evidence.branchKey,
|
|
185
|
+
directoryPath: expectedDirectoryPath,
|
|
186
|
+
slug: evidence.slug,
|
|
187
|
+
filePath: evidence.filePath,
|
|
188
|
+
fileName,
|
|
189
|
+
modifiedTimeMs: fileStat.mtimeMs,
|
|
190
|
+
...(evidence.summary === undefined ? {} : { summary: evidence.summary }),
|
|
191
|
+
};
|
|
192
|
+
return { type: "valid", plan };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function findLatestSessionSavedPlanFile(
|
|
196
|
+
entries: readonly unknown[],
|
|
197
|
+
directory: PlanStoreDirectoryEvidence,
|
|
198
|
+
options: ValidateSessionSavedPlanCandidateOptions = {},
|
|
199
|
+
): Promise<LatestSessionSavedPlanResult> {
|
|
200
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
201
|
+
const entry = entries[index];
|
|
202
|
+
const evidence = extractSavedPlanFileEvidenceFromSessionEntry(entry);
|
|
203
|
+
if (evidence === undefined) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const validation = await validateSessionSavedPlanCandidate(evidence, directory, options);
|
|
208
|
+
switch (validation.type) {
|
|
209
|
+
case "valid":
|
|
210
|
+
return { type: "found", plan: validation.plan };
|
|
211
|
+
case "stale":
|
|
212
|
+
continue;
|
|
213
|
+
case "unsafe":
|
|
214
|
+
return validation;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { type: "not-found" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function resolveSelectedSavedPlanFile(
|
|
222
|
+
pi: CommandExecApi,
|
|
223
|
+
options: ResolveSelectedSavedPlanFileOptions,
|
|
224
|
+
): Promise<SelectedSavedPlanFile> {
|
|
225
|
+
if (options.explicitPath !== undefined) {
|
|
226
|
+
const filePath = normalizePlanFilePath(options.explicitPath);
|
|
227
|
+
if (!isAbsolute(filePath)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Plan file path must be absolute or home-relative; got ${filePath || "(empty)"}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fileName = basename(filePath);
|
|
234
|
+
if (!fileName.endsWith(".md")) {
|
|
235
|
+
throw new Error(`Plan file must use a .md filename; got ${fileName || "(empty)"}.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
type: "explicit",
|
|
240
|
+
savedPlanFileStem: fileName.slice(0, -".md".length),
|
|
241
|
+
filePath,
|
|
242
|
+
fileName,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const sessionEntries = options.sessionEntries ?? [];
|
|
247
|
+
if (sessionEntries.length > 0) {
|
|
248
|
+
const directory = await resolvePlanStoreDirectory(pi, options);
|
|
249
|
+
const sessionResult = await findLatestSessionSavedPlanFile(sessionEntries, directory, {
|
|
250
|
+
shouldAllowSourceBranchMismatch: options.shouldAllowSessionSourceBranchMismatch ?? false,
|
|
251
|
+
...(options.planStoreGateway === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: { planStoreGateway: options.planStoreGateway }),
|
|
254
|
+
});
|
|
255
|
+
switch (sessionResult.type) {
|
|
256
|
+
case "found":
|
|
257
|
+
return {
|
|
258
|
+
type: "session",
|
|
259
|
+
plan: sessionResult.plan,
|
|
260
|
+
savedPlanFileStem: sessionResult.plan.slug,
|
|
261
|
+
};
|
|
262
|
+
case "unsafe":
|
|
263
|
+
throw new Error(sessionResult.message);
|
|
264
|
+
case "not-found":
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (options.shouldFallbackToLatest ?? false) {
|
|
270
|
+
const latest = await findLatestSavedPlanFile(pi, options);
|
|
271
|
+
return { type: "latest", plan: latest, savedPlanFileStem: latest.slug };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw new Error("No usable saved plan was found in the current session branch.");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type DirectoryMetadataValidation =
|
|
278
|
+
| { type: "valid"; directoryPath: string }
|
|
279
|
+
| { type: "invalid"; message: string };
|
|
280
|
+
|
|
281
|
+
function validateDirectoryMetadata(
|
|
282
|
+
evidence: SavedPlanFileEvidence,
|
|
283
|
+
directory: PlanStoreDirectoryEvidence,
|
|
284
|
+
options: ValidateSessionSavedPlanCandidateOptions,
|
|
285
|
+
): DirectoryMetadataValidation {
|
|
286
|
+
const mismatches = validateRepoMetadata(evidence, directory);
|
|
287
|
+
if (mismatches.length > 0) {
|
|
288
|
+
return metadataInvalid(mismatches);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const expectedBranchKey = encodeBranchForPlanPath(evidence.sourceBranch);
|
|
292
|
+
if (evidence.branchKey !== expectedBranchKey) {
|
|
293
|
+
return metadataInvalid([
|
|
294
|
+
`branchKey: evidence ${evidence.branchKey}, expected ${expectedBranchKey} for sourceBranch ${evidence.sourceBranch}`,
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const branchMatchesCurrent =
|
|
299
|
+
evidence.sourceBranch === directory.sourceBranch && evidence.branchKey === directory.branchKey;
|
|
300
|
+
if (branchMatchesCurrent) {
|
|
301
|
+
return { type: "valid", directoryPath: directory.directoryPath };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!(options.shouldAllowSourceBranchMismatch ?? false)) {
|
|
305
|
+
return metadataInvalid([
|
|
306
|
+
`sourceBranch: evidence ${evidence.sourceBranch}, current ${directory.sourceBranch}`,
|
|
307
|
+
`branchKey: evidence ${evidence.branchKey}, current ${directory.branchKey}`,
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
type: "valid",
|
|
313
|
+
directoryPath: buildPlanStoreBranchDirectoryPath({
|
|
314
|
+
repoDirectoryPath: directory.repoDirectoryPath,
|
|
315
|
+
branchKey: evidence.branchKey,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function validateRepoMetadata(
|
|
321
|
+
evidence: SavedPlanFileEvidence,
|
|
322
|
+
directory: PlanStoreDirectoryEvidence,
|
|
323
|
+
): string[] {
|
|
324
|
+
const mismatches: string[] = [];
|
|
325
|
+
if (evidence.repoRoot !== directory.repoRoot) {
|
|
326
|
+
mismatches.push(`repoRoot: evidence ${evidence.repoRoot}, current ${directory.repoRoot}`);
|
|
327
|
+
}
|
|
328
|
+
if (evidence.repoKey !== directory.repoKey) {
|
|
329
|
+
mismatches.push(`repoKey: evidence ${evidence.repoKey}, current ${directory.repoKey}`);
|
|
330
|
+
}
|
|
331
|
+
if (evidence.repoIdentitySource !== directory.repoIdentitySource) {
|
|
332
|
+
mismatches.push(
|
|
333
|
+
`repoIdentitySource: evidence ${evidence.repoIdentitySource}, current ${directory.repoIdentitySource}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return mismatches;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function metadataInvalid(mismatches: readonly string[]): DirectoryMetadataValidation {
|
|
340
|
+
return {
|
|
341
|
+
type: "invalid",
|
|
342
|
+
message: [
|
|
343
|
+
"Latest saved plan belongs to a different repo or branch than the current checkout, or an incompatible source branch.",
|
|
344
|
+
...mismatches,
|
|
345
|
+
].join("\n"),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatOutsidePlanStoreDirectoryMessage(
|
|
350
|
+
expectedDirectoryPath: string,
|
|
351
|
+
currentDirectoryPath: string,
|
|
352
|
+
): string {
|
|
353
|
+
if (expectedDirectoryPath === currentDirectoryPath) {
|
|
354
|
+
return "Session saved-plan evidence points outside the current local plan store directory.";
|
|
355
|
+
}
|
|
356
|
+
return "Session saved-plan evidence points outside the saved plan source-branch local plan store directory.";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function unsafe(message: string): SessionSavedPlanValidation {
|
|
360
|
+
return { type: "unsafe", message };
|
|
361
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PlanStoreDirectoryEntry,
|
|
5
|
+
PlanStoreDirectoryRead,
|
|
6
|
+
PlanStoreGateway,
|
|
7
|
+
PlanStorePathStat,
|
|
8
|
+
PlanStorePathType,
|
|
9
|
+
} from "./plan-store-gateway.ts";
|
|
10
|
+
|
|
11
|
+
interface InMemoryNode {
|
|
12
|
+
type: PlanStorePathType;
|
|
13
|
+
content?: string;
|
|
14
|
+
mtimeMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WriteInMemoryPlanStoreFileOptions {
|
|
18
|
+
mtimeMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class InMemoryPlanStoreGateway implements PlanStoreGateway {
|
|
22
|
+
private readonly nodes = new Map<string, InMemoryNode>();
|
|
23
|
+
private nextMtimeMs = 1;
|
|
24
|
+
|
|
25
|
+
constructor(paths: { directories?: readonly string[]; files?: Record<string, string> } = {}) {
|
|
26
|
+
for (const directory of paths.directories ?? []) this.mkdir(directory);
|
|
27
|
+
for (const [path, content] of Object.entries(paths.files ?? {})) this.writeFile(path, content);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async listDirectory(path: string): Promise<PlanStoreDirectoryRead> {
|
|
31
|
+
const normalizedPath = normalize(path);
|
|
32
|
+
const node = this.nodes.get(normalizedPath);
|
|
33
|
+
if (node === undefined) return { type: "missing" };
|
|
34
|
+
if (node.type !== "directory") return { type: "present", entries: [] };
|
|
35
|
+
|
|
36
|
+
const entries = new Map<string, PlanStoreDirectoryEntry>();
|
|
37
|
+
const prefix = normalizedPath === "/" ? "/" : `${normalizedPath}/`;
|
|
38
|
+
for (const [candidatePath, candidate] of this.nodes) {
|
|
39
|
+
if (candidatePath === normalizedPath || !candidatePath.startsWith(prefix)) continue;
|
|
40
|
+
const rest = candidatePath.slice(prefix.length);
|
|
41
|
+
if (rest.length === 0 || rest.includes("/")) continue;
|
|
42
|
+
entries.set(rest, { name: rest, type: candidate.type });
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
type: "present",
|
|
46
|
+
entries: [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async statPath(path: string): Promise<PlanStorePathStat | undefined> {
|
|
51
|
+
const node = this.nodes.get(normalize(path));
|
|
52
|
+
if (node === undefined) return undefined;
|
|
53
|
+
return { type: node.type, mtimeMs: node.mtimeMs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async readTextFile(path: string): Promise<string> {
|
|
57
|
+
const normalizedPath = normalize(path);
|
|
58
|
+
const node = this.nodes.get(normalizedPath);
|
|
59
|
+
if (node?.type !== "file" || node.content === undefined) {
|
|
60
|
+
throw new Error(`ENOENT: no such file or directory, open '${normalizedPath}'`);
|
|
61
|
+
}
|
|
62
|
+
return node.content;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async writeTextFileExclusive(path: string, content: string): Promise<void> {
|
|
66
|
+
const normalizedPath = normalize(path);
|
|
67
|
+
if (this.nodes.has(normalizedPath)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Saved plan file already exists in the local plan store; refusing to overwrite.\nPath: ${normalizedPath}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
this.writeFile(normalizedPath, content);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async realpathOrResolve(path: string): Promise<string> {
|
|
76
|
+
return normalize(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mkdir(path: string, options: { mtimeMs?: number } = {}): void {
|
|
80
|
+
const normalizedPath = normalize(path);
|
|
81
|
+
this.ensureParentDirectories(normalizedPath);
|
|
82
|
+
this.nodes.set(normalizedPath, {
|
|
83
|
+
type: "directory",
|
|
84
|
+
mtimeMs: options.mtimeMs ?? this.nextMtime(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
writeFile(path: string, content: string, options: WriteInMemoryPlanStoreFileOptions = {}): void {
|
|
89
|
+
const normalizedPath = normalize(path);
|
|
90
|
+
this.ensureParentDirectories(normalizedPath);
|
|
91
|
+
this.nodes.set(normalizedPath, {
|
|
92
|
+
type: "file",
|
|
93
|
+
content,
|
|
94
|
+
mtimeMs: options.mtimeMs ?? this.nextMtime(),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
readFile(path: string): string {
|
|
99
|
+
const normalizedPath = normalize(path);
|
|
100
|
+
const node = this.nodes.get(normalizedPath);
|
|
101
|
+
if (node?.type !== "file" || node.content === undefined) {
|
|
102
|
+
throw new Error(`In-memory plan store file does not exist: ${normalizedPath}`);
|
|
103
|
+
}
|
|
104
|
+
return node.content;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
hasFile(path: string): boolean {
|
|
108
|
+
return this.nodes.get(normalize(path))?.type === "file";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private ensureParentDirectories(path: string): void {
|
|
112
|
+
const parent = normalize(dirname(path));
|
|
113
|
+
if (parent === path) return;
|
|
114
|
+
if (!this.nodes.has(parent)) {
|
|
115
|
+
this.ensureParentDirectories(parent);
|
|
116
|
+
this.nodes.set(parent, { type: "directory", mtimeMs: this.nextMtime() });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private nextMtime(): number {
|
|
121
|
+
const value = this.nextMtimeMs;
|
|
122
|
+
this.nextMtimeMs += 1;
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalize(path: string): string {
|
|
128
|
+
return resolve("/", path);
|
|
129
|
+
}
|