-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontext.ts
More file actions
349 lines (314 loc) · 12.9 KB
/
Copy pathcontext.ts
File metadata and controls
349 lines (314 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { build_session_path } from './archive.js';
import { copy_to_container, exec_container } from './container_runtime.js';
import { stage_symlink_within_root } from './symlink_compatibility.js';
import { host_is_case_insensitive } from './path_containment.js';
export interface Resolved_Context_Entry {
/** Absolute path to the source on the host. */
source_path: string;
/** Path relative to `context/` (forward-slashed) — used both in archive and sandbox. */
archive_relative_path: string;
}
export interface Resolve_Context_Result {
entries: Resolved_Context_Entry[];
/** Human-readable warnings emitted during resolution (e.g., conflict skips). */
warnings: string[];
}
/**
* Resolve `--context` inputs into archive-bound entries.
*
* - Relative paths preserve their relative structure under `context/`
* - Absolute paths use only the filename
* - First-wins on archive-path conflicts; later inputs are skipped with a warning
*/
export function resolve_context_paths(
input_paths: string[],
base_directory: string = process.cwd()
): Resolve_Context_Result {
const entries: Resolved_Context_Entry[] = [];
const warnings: string[] = [];
const claimed = new Map<string, string>();
for (const input_path of input_paths) {
const expanded = expand_user_home(input_path);
const resolved_source = path.isAbsolute(expanded)
? path.resolve(expanded)
: path.resolve(base_directory, expanded);
if (!fs.existsSync(resolved_source)) {
warnings.push(`Context path does not exist; skipping: ${input_path}`);
continue;
}
const archive_relative = compute_archive_relative_path(expanded, base_directory);
// On a case-insensitive host (Windows/macOS) two inputs differing only
// in case resolve to the SAME archive file, so fold the conflict key to
// catch the collision the later copy would otherwise silently win. On a
// case-sensitive host the key is identity, so genuinely-distinct casings
// are not over-rejected.
const conflict_key = host_is_case_insensitive()
? archive_relative.toLowerCase()
: archive_relative;
const previous_source = claimed.get(conflict_key);
if (previous_source) {
warnings.push(
`Context path conflict: '${input_path}' resolves to '${archive_relative}' `
+ `which is already populated by '${previous_source}'. Skipping the later entry.`
);
continue;
}
claimed.set(conflict_key, resolved_source);
entries.push({ source_path: resolved_source, archive_relative_path: archive_relative });
}
return { entries, warnings };
}
function expand_user_home(input: string): string {
if (input === '~') {
return os.homedir();
}
if (input.startsWith('~/') || input.startsWith('~\\')) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function compute_archive_relative_path(
expanded_path: string,
base_directory: string
): string {
if (path.isAbsolute(expanded_path)) {
return path.basename(expanded_path);
}
const resolved = path.resolve(base_directory, expanded_path);
const relative = path.relative(base_directory, resolved);
if (relative === '' || relative.startsWith('..')) {
// Outside base directory — fall back to filename only.
return path.basename(expanded_path);
}
return relative.replaceAll('\\', '/');
}
export interface Prompt_File_Staging_Result {
merged_context_paths: string[];
container_files: string[];
}
/**
* Resolve `--prompt-file` paths for staging and OpenCode `--file` argv.
* Fail-closed: missing paths, directories, and destination collisions with
* `--context` or other prompt files all throw.
*/
export function resolve_prompt_file_staging(
prompt_file_paths: readonly string[],
context_paths: readonly string[],
image_home: string,
base_directory: string = process.cwd(),
): Prompt_File_Staging_Result {
if (prompt_file_paths.length === 0) {
return {
merged_context_paths: [...context_paths],
container_files: [],
};
}
const context_resolution = resolve_context_paths([...context_paths], base_directory);
const claimed = new Map<string, string>();
for (const entry of context_resolution.entries) {
const conflict_key = host_is_case_insensitive()
? entry.archive_relative_path.toLowerCase()
: entry.archive_relative_path;
claimed.set(conflict_key, entry.source_path);
}
const container_files: string[] = [];
const prompt_host_paths: string[] = [];
for (const input_path of prompt_file_paths) {
const expanded = expand_user_home(input_path);
const resolved_source = path.isAbsolute(expanded)
? path.resolve(expanded)
: path.resolve(base_directory, expanded);
if (!fs.existsSync(resolved_source)) {
throw new Prompt_File_Staging_Error(`--prompt-file path does not exist: ${input_path}`);
}
const stat = fs.lstatSync(resolved_source);
if (stat.isDirectory()) {
throw new Prompt_File_Staging_Error(`--prompt-file must be a file, not a directory: ${input_path}`);
}
const archive_relative = compute_archive_relative_path(expanded, base_directory);
const conflict_key = host_is_case_insensitive()
? archive_relative.toLowerCase()
: archive_relative;
const previous_source = claimed.get(conflict_key);
if (previous_source !== undefined && previous_source !== resolved_source) {
throw new Prompt_File_Staging_Error(
`--prompt-file '${input_path}' resolves to context destination '${archive_relative}' `
+ `which is already used by '${previous_source}'.`,
);
}
claimed.set(conflict_key, resolved_source);
prompt_host_paths.push(resolved_source);
container_files.push(`${image_home}/context/${archive_relative}`);
}
const merged = [...context_paths];
for (const host_path of prompt_host_paths) {
if (!merged.includes(host_path)) {
merged.push(host_path);
}
}
return { merged_context_paths: merged, container_files };
}
export class Prompt_File_Staging_Error extends Error {
constructor(message: string) {
super(message);
this.name = 'Prompt_File_Staging_Error';
}
}
/**
* Copy resolved context entries into the session's `context/` directory in the archive.
* Directory entries are copied recursively. The destination is created on demand.
*/
export function copy_context_to_archive(
entries: Resolved_Context_Entry[],
patchlab_id: string,
session_number: number
): void {
if (entries.length === 0) {
return;
}
const archive_root = build_session_path(patchlab_id, session_number, 'context');
fs.mkdirSync(archive_root, { recursive: true });
for (const entry of entries) {
const destination = path.join(archive_root, entry.archive_relative_path);
fs.mkdirSync(path.dirname(destination), { recursive: true });
// A real directory bounds its own symlinks; a symlink (or file) entry is
// bounded by its parent. Decided by lstat so a symlink-to-directory is
// treated as a link, not a directory.
const containing_root = fs.lstatSync(entry.source_path).isDirectory()
? entry.source_path
: path.dirname(entry.source_path);
copy_path_recursively(entry.source_path, destination, containing_root);
}
}
/**
* Copy a session's archived `context/` directory into the running container at
* `$HOME/context/` (sibling to `$HOME/workspace/`). Idempotent — safe to call
* after a previous overlay; later calls overwrite earlier files.
*/
export function copy_context_to_container(
container_name: string,
image_home: string,
archive_context_directory: string
): void {
if (!fs.existsSync(archive_context_directory)) {
return;
}
if (fs.readdirSync(archive_context_directory).length === 0) {
return;
}
const container_context = `${image_home}/context`;
exec_container(container_name, ['mkdir', '-p', container_context]);
copy_to_container(container_name, archive_context_directory + '/.', container_context);
}
/**
* Build a merged set of context entries by starting from the previous session's
* archive `context/` and overlaying new `--context` inputs. New entries replace
* any colliding archive entries; the caller is informed via `warnings`.
*/
export interface Merge_Resume_Context_Result {
entries: Resolved_Context_Entry[];
warnings: string[];
}
export function merge_resume_context(
previous_session_context_directory: string | null,
new_inputs: string[],
base_directory: string = process.cwd()
): Merge_Resume_Context_Result {
const new_resolution = resolve_context_paths(new_inputs, base_directory);
const new_paths = new Set(new_resolution.entries.map((entry) => entry.archive_relative_path));
const merged: Resolved_Context_Entry[] = [];
const warnings = [...new_resolution.warnings];
if (previous_session_context_directory && fs.existsSync(previous_session_context_directory)) {
for (const relative_path of list_relative_files(previous_session_context_directory)) {
if (new_paths.has(relative_path)) {
warnings.push(
`Context override: '${relative_path}' from previous session is replaced by --context input.`
);
continue;
}
merged.push({
source_path: path.join(previous_session_context_directory, relative_path),
archive_relative_path: relative_path,
});
}
}
merged.push(...new_resolution.entries);
// Surface basename collisions across different archive paths — the entries don't
// overwrite each other in the filesystem, but a tool that searches by basename
// (or a user scanning $HOME/context/ flat) may be surprised to find duplicates.
warnings.push(...detect_basename_collisions(merged));
return { entries: merged, warnings };
}
function detect_basename_collisions(entries: Resolved_Context_Entry[]): string[] {
const paths_by_basename = new Map<string, string[]>();
for (const entry of entries) {
const basename = path.basename(entry.archive_relative_path);
const existing = paths_by_basename.get(basename) ?? [];
existing.push(entry.archive_relative_path);
paths_by_basename.set(basename, existing);
}
const warnings: string[] = [];
for (const [basename, paths] of paths_by_basename) {
if (paths.length > 1) {
warnings.push(
`Context bundle has multiple files named '${basename}' at different paths: `
+ `${paths.join(', ')}. Tools that search by basename may pick the wrong one.`
);
}
}
return warnings;
}
export function copy_path_recursively(
source: string,
destination: string,
containing_root: string,
): void {
const stat = fs.lstatSync(source);
if (stat.isDirectory()) {
fs.mkdirSync(destination, { recursive: true });
for (const child of fs.readdirSync(source)) {
copy_path_recursively(
path.join(source, child),
path.join(destination, child),
containing_root,
);
}
return;
}
if (stat.isSymbolicLink()) {
try {
fs.unlinkSync(destination);
} catch (error) {
// ENOENT means there was nothing at `destination` to clear out
// before the symlink write — the happy path. Any other code
// (EACCES, EBUSY, EISDIR, EPERM) would block the symlink write
// that follows, so surface it now with the precise error
// instead of letting the downstream call fail with a vaguer one.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
stage_symlink_within_root(source, destination, containing_root, path.basename(source));
return;
}
fs.copyFileSync(source, destination);
fs.chmodSync(destination, stat.mode);
}
function list_relative_files(root: string): string[] {
const results: string[] = [];
walk(root, '', results);
return results;
}
function walk(root: string, prefix: string, accumulator: string[]): void {
for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })) {
const next = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(root, next, accumulator);
} else if (entry.isFile() || entry.isSymbolicLink()) {
accumulator.push(next);
}
}
}