@peakflo/agent-harness 0.2.3 → 0.2.4-rc.2
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/dist/adapters/coding-agent-adapter.d.ts +8 -0
- package/dist/adapters/pi-adapter.d.ts +3 -1
- package/dist/adapters/pi-adapter.js +52 -6
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.js +10 -0
- package/dist/contract.d.ts +4 -0
- package/dist/contract.js +4 -0
- package/dist/control-plane.d.ts +8 -1
- package/dist/control-plane.js +26 -0
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +10 -0
- package/dist/runtime/attachments.d.ts +6 -0
- package/dist/runtime/attachments.js +23 -15
- package/dist/runtime/conversation.d.ts +16 -0
- package/dist/runtime/conversation.js +15 -0
- package/dist/runtime/dev-server-guard.d.ts +43 -0
- package/dist/runtime/dev-server-guard.js +74 -0
- package/dist/runtime/knowledge-tools.d.ts +44 -0
- package/dist/runtime/knowledge-tools.js +410 -0
- package/dist/runtime/session-runner.d.ts +17 -0
- package/dist/runtime/session-runner.js +70 -9
- package/dist/runtime/space-files.d.ts +82 -0
- package/dist/runtime/space-files.js +422 -0
- package/dist/types.d.ts +80 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { LeasedSpaceFiles } from '../types';
|
|
2
|
+
export declare const SPACE_FILES_DIR = "space-files";
|
|
3
|
+
export declare const SPACE_FILES_MANIFEST_NAME = "MANIFEST.md";
|
|
4
|
+
export declare const SPACE_FILES_MANIFEST_PATH = "space-files/MANIFEST.md";
|
|
5
|
+
export declare const OUTPUTS_DIR = "outputs";
|
|
6
|
+
export declare const DOWNLOADS_DIR = "downloads";
|
|
7
|
+
/**
|
|
8
|
+
* Harness-side ceilings. They MIRROR the control plane's
|
|
9
|
+
* `SPACE_FILE_MAX_BYTES` and `SPACE_FILES_MAX_TOTAL_BYTES`. The effective limit
|
|
10
|
+
* is the SMALLER of the lease's value and this one, so an old control plane can
|
|
11
|
+
* never raise what this harness will write.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SPACE_FILE_MAX_BYTES: number;
|
|
14
|
+
export declare const SPACE_FILES_MAX_TOTAL_BYTES: number;
|
|
15
|
+
/** Per-file download wall clock. */
|
|
16
|
+
export declare const SPACE_FILE_DOWNLOAD_TIMEOUT_MS = 120000;
|
|
17
|
+
/** Replace control characters and separators with `_`, one character at a time. */
|
|
18
|
+
export declare function replaceControlCharacters(raw: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* `space-files/<segment>` or `space-files/<folder>/<segment>`, where a segment
|
|
21
|
+
* has no separator, no control character and is not `.`/`..` or dot-prefixed.
|
|
22
|
+
*/
|
|
23
|
+
export interface SpaceFilePresent {
|
|
24
|
+
path: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
mimeType: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface SpaceFileNotDownloaded {
|
|
29
|
+
path: string;
|
|
30
|
+
name: string;
|
|
31
|
+
sizeBytes: number;
|
|
32
|
+
reason: 'too_large' | 'workspace_limit';
|
|
33
|
+
}
|
|
34
|
+
export interface SpaceFilesSyncResult {
|
|
35
|
+
/** Everything now on disk under `space-files/` that the manifest lists. */
|
|
36
|
+
present: SpaceFilePresent[];
|
|
37
|
+
/** Downloaded this turn. */
|
|
38
|
+
downloaded: string[];
|
|
39
|
+
/** Already present and unchanged; not fetched again. */
|
|
40
|
+
kept: string[];
|
|
41
|
+
/** Deleted this turn: no longer in the manifest, or changed and then failed. */
|
|
42
|
+
removed: string[];
|
|
43
|
+
/** Listed, but could not be fetched. Logged, and left out of MANIFEST.md. */
|
|
44
|
+
failed: Array<{
|
|
45
|
+
path: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
}>;
|
|
48
|
+
/** Listed in MANIFEST.md with the reason, and no download attempted. */
|
|
49
|
+
notDownloaded: SpaceFileNotDownloaded[];
|
|
50
|
+
}
|
|
51
|
+
export interface SpaceFilesSyncOptions {
|
|
52
|
+
fetchImpl?: typeof fetch;
|
|
53
|
+
logger: Pick<Console, 'log' | 'warn'>;
|
|
54
|
+
downloadTimeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
/** A path the harness will write to, or null. The manifest is untrusted input. */
|
|
57
|
+
export declare function isSafeSpaceFilePath(path: unknown): path is string;
|
|
58
|
+
/**
|
|
59
|
+
* Stream a signed URL into `dest`, refusing anything over `maxBytes`. The
|
|
60
|
+
* manifest's size is the authority: a body of a different length is a file that
|
|
61
|
+
* changed under us, and it is not kept.
|
|
62
|
+
*/
|
|
63
|
+
export declare function downloadTo(url: string, dest: string, options: {
|
|
64
|
+
maxBytes: number;
|
|
65
|
+
timeoutMs: number;
|
|
66
|
+
fetchImpl: typeof fetch;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* `space-files/MANIFEST.md`. Written every turn. It names only present files as
|
|
70
|
+
* readable; anything else is listed under "Not downloaded" with the reason.
|
|
71
|
+
*/
|
|
72
|
+
export declare function renderSpaceFilesManifest(result: SpaceFilesSyncResult): string;
|
|
73
|
+
/**
|
|
74
|
+
* Make the workspace's `space-files/` match `manifest`. Returns what happened.
|
|
75
|
+
*
|
|
76
|
+
* `manifest` undefined means the turn has no Space files at all. The same
|
|
77
|
+
* cleanup runs, so a chat that moved to a Space with no files does not keep the
|
|
78
|
+
* previous Space's copies on disk.
|
|
79
|
+
*/
|
|
80
|
+
export declare function syncSpaceFiles(manifest: LeasedSpaceFiles | undefined, workspaceDir: string, options: SpaceFilesSyncOptions): Promise<SpaceFilesSyncResult>;
|
|
81
|
+
/** Whether this workspace has a sync record from an earlier turn. */
|
|
82
|
+
export declare function hasSpaceFilesState(workspaceDir: string): Promise<boolean>;
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SPACE_FILE_DOWNLOAD_TIMEOUT_MS = exports.SPACE_FILES_MAX_TOTAL_BYTES = exports.SPACE_FILE_MAX_BYTES = exports.DOWNLOADS_DIR = exports.OUTPUTS_DIR = exports.SPACE_FILES_MANIFEST_PATH = exports.SPACE_FILES_MANIFEST_NAME = exports.SPACE_FILES_DIR = void 0;
|
|
4
|
+
exports.replaceControlCharacters = replaceControlCharacters;
|
|
5
|
+
exports.isSafeSpaceFilePath = isSafeSpaceFilePath;
|
|
6
|
+
exports.downloadTo = downloadTo;
|
|
7
|
+
exports.renderSpaceFilesManifest = renderSpaceFilesManifest;
|
|
8
|
+
exports.syncSpaceFiles = syncSpaceFiles;
|
|
9
|
+
exports.hasSpaceFilesState = hasSpaceFilesState;
|
|
10
|
+
/**
|
|
11
|
+
* SPACE FILES → THE WORKSPACE, READ-ONLY (P9).
|
|
12
|
+
*
|
|
13
|
+
* The lease carries `session.spaceFiles`: the files of the chat's Space and its
|
|
14
|
+
* ancestors that the speaker may read, each with a short-lived signed URL. This
|
|
15
|
+
* module makes the workspace match that list on EVERY turn:
|
|
16
|
+
*
|
|
17
|
+
* - a file that is new, or changed (its `updatedAt` or size moved), is
|
|
18
|
+
* downloaded to a temp file and renamed into place;
|
|
19
|
+
* - a file that is already there and unchanged is left alone;
|
|
20
|
+
* - a file no longer in the list is deleted, and so is anything else under
|
|
21
|
+
* `space-files/` that is not in the list (a stale mirror cannot survive);
|
|
22
|
+
* - every file is made read-only (0444). The agent reads these; new or
|
|
23
|
+
* changed work goes to `outputs/`.
|
|
24
|
+
*
|
|
25
|
+
* `space-files/MANIFEST.md` lists ONLY what is actually present, plus the files
|
|
26
|
+
* that were skipped for size, so the model never plans around a file it cannot
|
|
27
|
+
* open. A stopped or resumed sandbox re-syncs on its next turn, because the
|
|
28
|
+
* check is against the disk, not against a memory of the last turn.
|
|
29
|
+
*
|
|
30
|
+
* NEVER FATAL. A file that fails is logged by path and left out; the turn runs
|
|
31
|
+
* with the rest. URLs are never logged, and error text is scrubbed of them.
|
|
32
|
+
*
|
|
33
|
+
* THE LEASE IS INPUT. The manifest crosses the wire, so every path is checked
|
|
34
|
+
* against the expected shape again here, and anything that does not fit is
|
|
35
|
+
* dropped. A path can never leave `space-files/`.
|
|
36
|
+
*/
|
|
37
|
+
const node_crypto_1 = require("node:crypto");
|
|
38
|
+
const node_fs_1 = require("node:fs");
|
|
39
|
+
const promises_1 = require("node:fs/promises");
|
|
40
|
+
const node_path_1 = require("node:path");
|
|
41
|
+
const node_stream_1 = require("node:stream");
|
|
42
|
+
const promises_2 = require("node:stream/promises");
|
|
43
|
+
const attachments_1 = require("./attachments");
|
|
44
|
+
exports.SPACE_FILES_DIR = 'space-files';
|
|
45
|
+
exports.SPACE_FILES_MANIFEST_NAME = 'MANIFEST.md';
|
|
46
|
+
exports.SPACE_FILES_MANIFEST_PATH = `${exports.SPACE_FILES_DIR}/${exports.SPACE_FILES_MANIFEST_NAME}`;
|
|
47
|
+
exports.OUTPUTS_DIR = 'outputs';
|
|
48
|
+
exports.DOWNLOADS_DIR = 'downloads';
|
|
49
|
+
/** Hidden, at the workspace root. Records what this workspace was last synced with. */
|
|
50
|
+
const STATE_FILE = '.pf-space-files.json';
|
|
51
|
+
const TEMP_PREFIX = '.tmp-';
|
|
52
|
+
/**
|
|
53
|
+
* Harness-side ceilings. They MIRROR the control plane's
|
|
54
|
+
* `SPACE_FILE_MAX_BYTES` and `SPACE_FILES_MAX_TOTAL_BYTES`. The effective limit
|
|
55
|
+
* is the SMALLER of the lease's value and this one, so an old control plane can
|
|
56
|
+
* never raise what this harness will write.
|
|
57
|
+
*/
|
|
58
|
+
exports.SPACE_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
59
|
+
exports.SPACE_FILES_MAX_TOTAL_BYTES = 200 * 1024 * 1024;
|
|
60
|
+
/** Per-file download wall clock. */
|
|
61
|
+
exports.SPACE_FILE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
62
|
+
/** Replace control characters and separators with `_`, one character at a time. */
|
|
63
|
+
function replaceControlCharacters(raw) {
|
|
64
|
+
let out = '';
|
|
65
|
+
for (const ch of raw) {
|
|
66
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
67
|
+
out += code < 32 || code === 127 || '\\/'.includes(ch) ? '_' : ch;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** One path segment: 1 to 200 characters, with no separator and no control character. */
|
|
72
|
+
function isPlainSegment(segment) {
|
|
73
|
+
return (segment.length >= 1 &&
|
|
74
|
+
segment.length <= 200 &&
|
|
75
|
+
replaceControlCharacters(segment) === segment);
|
|
76
|
+
}
|
|
77
|
+
/** A path the harness will write to, or null. The manifest is untrusted input. */
|
|
78
|
+
function isSafeSpaceFilePath(path) {
|
|
79
|
+
if (typeof path !== 'string' || path.length > 512)
|
|
80
|
+
return false;
|
|
81
|
+
const parts = path.split('/');
|
|
82
|
+
if (parts[0] !== exports.SPACE_FILES_DIR)
|
|
83
|
+
return false;
|
|
84
|
+
if (parts.length < 2 || parts.length > 3)
|
|
85
|
+
return false;
|
|
86
|
+
if (path === exports.SPACE_FILES_MANIFEST_PATH)
|
|
87
|
+
return false;
|
|
88
|
+
return parts
|
|
89
|
+
.slice(1)
|
|
90
|
+
.every((segment) => isPlainSegment(segment) &&
|
|
91
|
+
segment !== '.' &&
|
|
92
|
+
segment !== '..' &&
|
|
93
|
+
!segment.startsWith('.') &&
|
|
94
|
+
segment.trim() === segment);
|
|
95
|
+
}
|
|
96
|
+
function scrub(message) {
|
|
97
|
+
return message.replace(/https?:\/\/\S+/gi, '<url>').slice(0, 300);
|
|
98
|
+
}
|
|
99
|
+
async function sizeOf(file) {
|
|
100
|
+
try {
|
|
101
|
+
const info = await (0, promises_1.stat)(file);
|
|
102
|
+
return info.isFile() ? info.size : null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function readState(root) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(root, STATE_FILE), 'utf8'));
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [path, entry] of Object.entries(parsed.files ?? {})) {
|
|
113
|
+
if (typeof entry?.key === 'string' && typeof entry.sizeBytes === 'number')
|
|
114
|
+
out[path] = { key: entry.key, sizeBytes: entry.sizeBytes };
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function writeAtomic(file, contents) {
|
|
123
|
+
const tmp = (0, node_path_1.join)((0, node_path_1.dirname)(file), `${TEMP_PREFIX}${(0, node_crypto_1.randomUUID)()}`);
|
|
124
|
+
await (0, promises_1.writeFile)(tmp, contents, { mode: 0o644 });
|
|
125
|
+
await (0, promises_1.rename)(tmp, file);
|
|
126
|
+
}
|
|
127
|
+
/** The change key. A new `updatedAt` or a new size is a changed file. */
|
|
128
|
+
function changeKey(file) {
|
|
129
|
+
return `${file.fileId}:${file.updatedAt ?? ''}:${file.sizeBytes}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Stream a signed URL into `dest`, refusing anything over `maxBytes`. The
|
|
133
|
+
* manifest's size is the authority: a body of a different length is a file that
|
|
134
|
+
* changed under us, and it is not kept.
|
|
135
|
+
*/
|
|
136
|
+
async function downloadTo(url, dest, options) {
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
139
|
+
try {
|
|
140
|
+
// A signed URL does not redirect. A redirect would be a host we did not sign for.
|
|
141
|
+
const response = await options.fetchImpl(url, {
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
redirect: 'error'
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok)
|
|
146
|
+
throw new Error(`storage answered HTTP ${response.status}`);
|
|
147
|
+
if (!response.body)
|
|
148
|
+
throw new Error('storage returned an empty body');
|
|
149
|
+
const declared = Number(response.headers.get('content-length'));
|
|
150
|
+
if (Number.isFinite(declared) && declared > options.maxBytes)
|
|
151
|
+
throw new Error('storage body is larger than the manifest size');
|
|
152
|
+
let received = 0;
|
|
153
|
+
const meter = new node_stream_1.Transform({
|
|
154
|
+
transform(chunk, _encoding, callback) {
|
|
155
|
+
received += chunk.length;
|
|
156
|
+
if (received > options.maxBytes) {
|
|
157
|
+
callback(new Error('storage body is larger than the manifest size'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
callback(null, chunk);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
await (0, promises_2.pipeline)(node_stream_1.Readable.fromWeb(response.body), meter, (0, node_fs_1.createWriteStream)(dest, { flags: 'wx' }));
|
|
164
|
+
if (received !== options.maxBytes)
|
|
165
|
+
throw new Error(`size mismatch: manifest ${options.maxBytes}, received ${received}`);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Every regular file under `space-files/`, as workspace-relative paths. */
|
|
172
|
+
async function listManagedFiles(root) {
|
|
173
|
+
const out = [];
|
|
174
|
+
const walk = async (relative, depth) => {
|
|
175
|
+
if (depth > 3)
|
|
176
|
+
return;
|
|
177
|
+
let entries;
|
|
178
|
+
try {
|
|
179
|
+
entries = await (0, promises_1.readdir)((0, node_path_1.join)(root, relative), { withFileTypes: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const next = `${relative}/${entry.name}`;
|
|
186
|
+
if (entry.isDirectory())
|
|
187
|
+
await walk(next, depth + 1);
|
|
188
|
+
else if (entry.isFile())
|
|
189
|
+
out.push(next);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
await walk(exports.SPACE_FILES_DIR, 0);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** Remove now-empty folders under `space-files/`, deepest first. Never the root. */
|
|
196
|
+
async function pruneEmptyFolders(root) {
|
|
197
|
+
const walk = async (relative, depth) => {
|
|
198
|
+
if (depth > 3)
|
|
199
|
+
return;
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = await (0, promises_1.readdir)((0, node_path_1.join)(root, relative), { withFileTypes: true });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (entry.isDirectory())
|
|
209
|
+
await walk(`${relative}/${entry.name}`, depth + 1);
|
|
210
|
+
}
|
|
211
|
+
if (relative !== exports.SPACE_FILES_DIR) {
|
|
212
|
+
try {
|
|
213
|
+
await (0, promises_1.rmdir)((0, node_path_1.join)(root, relative));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Not empty, or already gone. Either is fine.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
await walk(exports.SPACE_FILES_DIR, 0);
|
|
221
|
+
}
|
|
222
|
+
function formatBytes(bytes) {
|
|
223
|
+
if (bytes < 1024)
|
|
224
|
+
return `${bytes} B`;
|
|
225
|
+
if (bytes < 1024 * 1024)
|
|
226
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
227
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
228
|
+
}
|
|
229
|
+
/** A file name shown in Markdown. Backticks would end the code span. */
|
|
230
|
+
function codeSpan(text) {
|
|
231
|
+
return `\`${text.replace(/`/g, "'")}\``;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* `space-files/MANIFEST.md`. Written every turn. It names only present files as
|
|
235
|
+
* readable; anything else is listed under "Not downloaded" with the reason.
|
|
236
|
+
*/
|
|
237
|
+
function renderSpaceFilesManifest(result) {
|
|
238
|
+
const lines = [
|
|
239
|
+
'# Space files',
|
|
240
|
+
'',
|
|
241
|
+
"Read-only copies of the files in this chat's Space and its parent Spaces.",
|
|
242
|
+
'Changes made here are discarded at the next turn. Write new or changed files to `outputs/`.',
|
|
243
|
+
'',
|
|
244
|
+
`## Files (${result.present.length})`,
|
|
245
|
+
''
|
|
246
|
+
];
|
|
247
|
+
if (!result.present.length) {
|
|
248
|
+
lines.push('No files are present.', '');
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
for (const file of [...result.present].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
252
|
+
lines.push(`- ${codeSpan(file.path)}: ${formatBytes(file.sizeBytes)}, ${file.mimeType ?? 'unknown type'}`);
|
|
253
|
+
}
|
|
254
|
+
lines.push('');
|
|
255
|
+
}
|
|
256
|
+
if (result.notDownloaded.length) {
|
|
257
|
+
lines.push('## Not downloaded', '');
|
|
258
|
+
for (const file of result.notDownloaded) {
|
|
259
|
+
const why = file.reason === 'too_large'
|
|
260
|
+
? `not downloaded (too large, over ${formatBytes(exports.SPACE_FILE_MAX_BYTES)})`
|
|
261
|
+
: 'not downloaded (workspace size limit reached)';
|
|
262
|
+
lines.push(`- ${codeSpan(file.path)}: ${formatBytes(file.sizeBytes)}, ${why}. Use knowledge_file_download to fetch it into downloads/.`);
|
|
263
|
+
}
|
|
264
|
+
lines.push('');
|
|
265
|
+
}
|
|
266
|
+
return `${lines.join('\n')}\n`;
|
|
267
|
+
}
|
|
268
|
+
async function writeManifest(root, result) {
|
|
269
|
+
const file = (0, node_path_1.join)(root, exports.SPACE_FILES_MANIFEST_PATH);
|
|
270
|
+
await (0, promises_1.rm)(file, { force: true });
|
|
271
|
+
const tmp = (0, node_path_1.join)(root, exports.SPACE_FILES_DIR, `${TEMP_PREFIX}${(0, node_crypto_1.randomUUID)()}`);
|
|
272
|
+
await (0, promises_1.writeFile)(tmp, renderSpaceFilesManifest(result), { mode: 0o444 });
|
|
273
|
+
await (0, promises_1.rename)(tmp, file);
|
|
274
|
+
await (0, promises_1.chmod)(file, 0o444);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Make the workspace's `space-files/` match `manifest`. Returns what happened.
|
|
278
|
+
*
|
|
279
|
+
* `manifest` undefined means the turn has no Space files at all. The same
|
|
280
|
+
* cleanup runs, so a chat that moved to a Space with no files does not keep the
|
|
281
|
+
* previous Space's copies on disk.
|
|
282
|
+
*/
|
|
283
|
+
async function syncSpaceFiles(manifest, workspaceDir, options) {
|
|
284
|
+
const root = (0, node_path_1.resolve)(workspaceDir);
|
|
285
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
286
|
+
const { logger } = options;
|
|
287
|
+
const timeoutMs = options.downloadTimeoutMs ?? exports.SPACE_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
288
|
+
const maxFile = Math.min(manifest?.limits?.maxFileBytes ?? exports.SPACE_FILE_MAX_BYTES, exports.SPACE_FILE_MAX_BYTES);
|
|
289
|
+
const maxTotal = Math.min(manifest?.limits?.maxTotalBytes ?? exports.SPACE_FILES_MAX_TOTAL_BYTES, exports.SPACE_FILES_MAX_TOTAL_BYTES);
|
|
290
|
+
const result = {
|
|
291
|
+
present: [],
|
|
292
|
+
downloaded: [],
|
|
293
|
+
kept: [],
|
|
294
|
+
removed: [],
|
|
295
|
+
failed: [],
|
|
296
|
+
notDownloaded: []
|
|
297
|
+
};
|
|
298
|
+
await (0, promises_1.mkdir)((0, node_path_1.join)(root, exports.SPACE_FILES_DIR), { recursive: true });
|
|
299
|
+
await (0, promises_1.mkdir)((0, node_path_1.join)(root, exports.OUTPUTS_DIR), { recursive: true });
|
|
300
|
+
// The mirror and its state file are not work product. Keep them out of a repo's `git status`.
|
|
301
|
+
await (0, attachments_1.excludePatternFromGit)(root, `/${exports.SPACE_FILES_DIR}/`, logger);
|
|
302
|
+
await (0, attachments_1.excludePatternFromGit)(root, `/${STATE_FILE}`, logger);
|
|
303
|
+
// 1. Validate the lease's list. Anything that does not fit is dropped here.
|
|
304
|
+
const desired = new Map();
|
|
305
|
+
let plannedBytes = 0;
|
|
306
|
+
for (const file of manifest?.files ?? []) {
|
|
307
|
+
if (!file || !isSafeSpaceFilePath(file.path) || desired.has(file.path)) {
|
|
308
|
+
logger.warn('[space-files] manifest entry dropped: unsafe or duplicate path');
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (typeof file.url !== 'string' || !/^https?:\/\//i.test(file.url)) {
|
|
312
|
+
logger.warn(`[space-files] ${file.path} dropped: URL is not fetchable`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const size = Number(file.sizeBytes);
|
|
316
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
317
|
+
continue;
|
|
318
|
+
if (size > maxFile) {
|
|
319
|
+
result.notDownloaded.push({
|
|
320
|
+
path: file.path,
|
|
321
|
+
name: file.name,
|
|
322
|
+
sizeBytes: size,
|
|
323
|
+
reason: 'too_large'
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (plannedBytes + size > maxTotal) {
|
|
328
|
+
result.notDownloaded.push({
|
|
329
|
+
path: file.path,
|
|
330
|
+
name: file.name,
|
|
331
|
+
sizeBytes: size,
|
|
332
|
+
reason: 'workspace_limit'
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
plannedBytes += size;
|
|
337
|
+
desired.set(file.path, file);
|
|
338
|
+
}
|
|
339
|
+
for (const skipped of manifest?.skipped ?? []) {
|
|
340
|
+
if (!isSafeSpaceFilePath(skipped?.path))
|
|
341
|
+
continue;
|
|
342
|
+
if (desired.has(skipped.path))
|
|
343
|
+
continue;
|
|
344
|
+
result.notDownloaded.push({
|
|
345
|
+
path: skipped.path,
|
|
346
|
+
name: skipped.name,
|
|
347
|
+
sizeBytes: Number(skipped.sizeBytes) || 0,
|
|
348
|
+
reason: skipped.reason === 'too_large' ? 'too_large' : 'workspace_limit'
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const state = await readState(root);
|
|
352
|
+
const nextState = {};
|
|
353
|
+
// 2. Bring each listed file to the listed state, one at a time.
|
|
354
|
+
for (const file of desired.values()) {
|
|
355
|
+
const dest = (0, node_path_1.join)(root, file.path);
|
|
356
|
+
const key = changeKey(file);
|
|
357
|
+
const onDisk = await sizeOf(dest);
|
|
358
|
+
if (state[file.path]?.key === key && onDisk === file.sizeBytes) {
|
|
359
|
+
result.kept.push(file.path);
|
|
360
|
+
nextState[file.path] = { key, sizeBytes: file.sizeBytes };
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// The temp file sits in the mirror's root, not beside `dest`: a folder for
|
|
364
|
+
// an inherited Space may not exist yet, and a rename within one tree is atomic.
|
|
365
|
+
const tmp = (0, node_path_1.join)(root, exports.SPACE_FILES_DIR, `${TEMP_PREFIX}${(0, node_crypto_1.randomUUID)()}`);
|
|
366
|
+
try {
|
|
367
|
+
await downloadTo(file.url, tmp, {
|
|
368
|
+
maxBytes: file.sizeBytes,
|
|
369
|
+
timeoutMs,
|
|
370
|
+
fetchImpl
|
|
371
|
+
});
|
|
372
|
+
await (0, promises_1.chmod)(tmp, 0o444);
|
|
373
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(dest), { recursive: true });
|
|
374
|
+
// A same-named read-only file from the last turn is replaced by rename.
|
|
375
|
+
await (0, promises_1.rename)(tmp, dest);
|
|
376
|
+
result.downloaded.push(file.path);
|
|
377
|
+
nextState[file.path] = { key, sizeBytes: file.sizeBytes };
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
await (0, promises_1.rm)(tmp, { force: true });
|
|
381
|
+
// A changed file that could not be fetched must not stay as stale content.
|
|
382
|
+
if (onDisk !== null) {
|
|
383
|
+
await (0, promises_1.rm)(dest, { force: true });
|
|
384
|
+
result.removed.push(file.path);
|
|
385
|
+
}
|
|
386
|
+
const reason = scrub(error instanceof Error ? error.message : String(error));
|
|
387
|
+
result.failed.push({ path: file.path, reason });
|
|
388
|
+
logger.warn(`[space-files] ${file.path} not synced: ${reason}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// 3. Delete everything under space-files/ that the list does not keep.
|
|
392
|
+
// A leftover temp file from a crashed sync is not a Space file: it goes
|
|
393
|
+
// quietly. Anything else the list does not keep is reported as removed.
|
|
394
|
+
const keep = new Set([...result.kept, ...result.downloaded]);
|
|
395
|
+
for (const relative of await listManagedFiles(root)) {
|
|
396
|
+
if (relative === exports.SPACE_FILES_MANIFEST_PATH || keep.has(relative))
|
|
397
|
+
continue;
|
|
398
|
+
const isTemp = (relative.split('/').pop() ?? '').startsWith(TEMP_PREFIX);
|
|
399
|
+
await (0, promises_1.rm)((0, node_path_1.join)(root, relative), { force: true });
|
|
400
|
+
if (!isTemp && !result.removed.includes(relative))
|
|
401
|
+
result.removed.push(relative);
|
|
402
|
+
}
|
|
403
|
+
await pruneEmptyFolders(root);
|
|
404
|
+
// 4. Record what is now present, so the next turn can tell a change from a copy.
|
|
405
|
+
for (const path of [...result.kept, ...result.downloaded]) {
|
|
406
|
+
const size = (await sizeOf((0, node_path_1.join)(root, path))) ?? 0;
|
|
407
|
+
result.present.push({
|
|
408
|
+
path,
|
|
409
|
+
sizeBytes: size,
|
|
410
|
+
mimeType: desired.get(path)?.mimeType ?? null
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
await writeAtomic((0, node_path_1.join)(root, STATE_FILE), `${JSON.stringify({ version: 1, files: nextState }, null, 2)}\n`);
|
|
414
|
+
// 5. The manifest: what is present, then what was skipped and why.
|
|
415
|
+
await writeManifest(root, result);
|
|
416
|
+
logger.log(`[space-files] synced ${result.present.length} file(s): ${result.downloaded.length} downloaded, ${result.kept.length} unchanged, ${result.removed.length} removed, ${result.failed.length} failed, ${result.notDownloaded.length} not downloaded`);
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
/** Whether this workspace has a sync record from an earlier turn. */
|
|
420
|
+
async function hasSpaceFilesState(workspaceDir) {
|
|
421
|
+
return (await sizeOf((0, node_path_1.join)((0, node_path_1.resolve)(workspaceDir), STATE_FILE))) !== null;
|
|
422
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -216,6 +216,13 @@ export interface RunnerCapabilities {
|
|
|
216
216
|
maxConcurrentSessions: number;
|
|
217
217
|
/** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
|
|
218
218
|
labels?: string[];
|
|
219
|
+
/**
|
|
220
|
+
* Named features this harness implements, so the control plane can gate
|
|
221
|
+
* behaviour on them. A harness that does not list a feature must be treated
|
|
222
|
+
* as not having it: `spaceFiles` means the harness syncs `space-files/` and
|
|
223
|
+
* serves `knowledge_files_list` / `knowledge_file_download`.
|
|
224
|
+
*/
|
|
225
|
+
features?: string[];
|
|
219
226
|
}
|
|
220
227
|
export interface RunnerBackend {
|
|
221
228
|
/** 'opencode' | 'claude-code' | 'codex' | 'acp' | ... — an open vocabulary. */
|
|
@@ -450,6 +457,11 @@ export interface LeasedSession {
|
|
|
450
457
|
backendOwnerId?: string | null;
|
|
451
458
|
workspace: LeasedWorkspace;
|
|
452
459
|
limits: LeasedLimits;
|
|
460
|
+
/**
|
|
461
|
+
* The Space files manifest for this turn (P9). Absent for a turn with no
|
|
462
|
+
* Space, and for a control plane that predates the capability.
|
|
463
|
+
*/
|
|
464
|
+
spaceFiles?: LeasedSpaceFiles;
|
|
453
465
|
}
|
|
454
466
|
export interface LeasedAgent {
|
|
455
467
|
agentId: string;
|
|
@@ -467,6 +479,13 @@ export interface LeasedAgent {
|
|
|
467
479
|
/** Always the skill's PUBLISHED content, never its draft. */
|
|
468
480
|
skills: LeasedSkill[];
|
|
469
481
|
mcpServers: LeasedMcpServer[];
|
|
482
|
+
/**
|
|
483
|
+
* Optional and additive. Absent means an older control plane, and the
|
|
484
|
+
* harness then applies no dev-server refusal.
|
|
485
|
+
*/
|
|
486
|
+
devServerPolicy?: {
|
|
487
|
+
blockDevServers: boolean;
|
|
488
|
+
};
|
|
470
489
|
}
|
|
471
490
|
export interface LeasedModel {
|
|
472
491
|
provider: string;
|
|
@@ -532,6 +551,67 @@ export interface LeasedAttachment {
|
|
|
532
551
|
url: string;
|
|
533
552
|
sizeBytes?: number;
|
|
534
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* The Space files manifest (P9). Present only when the runner advertised the
|
|
556
|
+
* `spaceFiles` capability. The control plane decides WHICH files; the harness
|
|
557
|
+
* decides WHERE they land and whether they are still there.
|
|
558
|
+
*/
|
|
559
|
+
export interface LeasedSpaceFiles {
|
|
560
|
+
limits: {
|
|
561
|
+
maxFileBytes: number;
|
|
562
|
+
maxTotalBytes: number;
|
|
563
|
+
urlTtlSeconds: number;
|
|
564
|
+
};
|
|
565
|
+
files: LeasedSpaceFile[];
|
|
566
|
+
/** Present in storage, not downloaded: too large, or over the workspace budget. */
|
|
567
|
+
skipped: LeasedSpaceFileSkip[];
|
|
568
|
+
/** More files exist than the manifest lists. */
|
|
569
|
+
truncated: boolean;
|
|
570
|
+
}
|
|
571
|
+
export interface LeasedSpaceFile {
|
|
572
|
+
fileId: string;
|
|
573
|
+
datastoreId: string;
|
|
574
|
+
spaceId: string;
|
|
575
|
+
spaceName: string;
|
|
576
|
+
/** Relative to the workspace, e.g. `space-files/Sales/q3.csv`. */
|
|
577
|
+
path: string;
|
|
578
|
+
name: string;
|
|
579
|
+
sizeBytes: number;
|
|
580
|
+
mimeType: string | null;
|
|
581
|
+
updatedAt: string | null;
|
|
582
|
+
/** Short-lived signed URL. Fetched by the harness and never logged. */
|
|
583
|
+
url: string;
|
|
584
|
+
}
|
|
585
|
+
export interface LeasedSpaceFileSkip {
|
|
586
|
+
fileId: string;
|
|
587
|
+
path: string;
|
|
588
|
+
name: string;
|
|
589
|
+
sizeBytes: number;
|
|
590
|
+
mimeType: string | null;
|
|
591
|
+
reason: 'too_large' | 'workspace_limit';
|
|
592
|
+
}
|
|
593
|
+
/** One knowledge file the agent may read: metadata only, never a URL or path. */
|
|
594
|
+
export interface KnowledgeFileListing {
|
|
595
|
+
fileId: string;
|
|
596
|
+
datastoreId: string;
|
|
597
|
+
datastoreName: string;
|
|
598
|
+
name: string;
|
|
599
|
+
mimeType: string | null;
|
|
600
|
+
sizeBytes: number | null;
|
|
601
|
+
status: 'ready' | 'pending';
|
|
602
|
+
}
|
|
603
|
+
export interface KnowledgeFilesResponse {
|
|
604
|
+
files: KnowledgeFileListing[];
|
|
605
|
+
truncated: boolean;
|
|
606
|
+
}
|
|
607
|
+
export interface KnowledgeDownloadResponse {
|
|
608
|
+
name: string;
|
|
609
|
+
mimeType: string | null;
|
|
610
|
+
sizeBytes: number;
|
|
611
|
+
/** Short-lived. Fetched by the harness, never shown to the model or logged. */
|
|
612
|
+
url: string;
|
|
613
|
+
expiresInSeconds: number;
|
|
614
|
+
}
|
|
535
615
|
export interface LeasedWorkspace {
|
|
536
616
|
/**
|
|
537
617
|
* 'scratch' is a bare working directory and is the default — the product
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4-rc.2",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|