@myagentroam/node 0.10.3 → 0.10.4
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/connector.js +18 -4
- package/dist/runner/opencode/managed-run-controller.js +12 -2
- package/dist/service/run-attachment-service.js +64 -20
- package/dist/service/session-message-service.js +16 -10
- package/dist/service/workspace-file-service.js +43 -3
- package/dist/service/workspace-git-exclude-service.js +7 -5
- package/dist/service/workspace-upload-service.js +90 -42
- package/package.json +3 -3
package/dist/connector.js
CHANGED
|
@@ -316,13 +316,19 @@ export class NodeConnector {
|
|
|
316
316
|
workspaceFileService = new WorkspaceFileService(this.workspaceState, (workspaceId) => {
|
|
317
317
|
this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
|
|
318
318
|
this.workspaceCoordinator.invalidate(workspaceId);
|
|
319
|
-
})
|
|
319
|
+
}, {}, (workspaceId, resourcePath) => this.workspaceUploadService.fileResource(workspaceId, resourcePath) ??
|
|
320
|
+
this.runAttachmentService.fileResource(workspaceId, resourcePath));
|
|
320
321
|
workspaceUploadService = new WorkspaceUploadService(this.workspaceState, (workspaceId) => {
|
|
321
322
|
this.workspaceFileService.dispose(workspaceId);
|
|
322
323
|
this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
|
|
323
324
|
this.workspaceCoordinator.invalidate(workspaceId);
|
|
324
|
-
});
|
|
325
|
+
}, () => this.attachmentTemporaryRoot());
|
|
325
326
|
directTransferService;
|
|
327
|
+
attachmentTemporaryRoot() {
|
|
328
|
+
if (this.config === undefined)
|
|
329
|
+
throw new Error('NODE_CONFIG_UNAVAILABLE');
|
|
330
|
+
return resolve(nodeDataDirectory(this.config, this.configPath), 'temp-files');
|
|
331
|
+
}
|
|
326
332
|
workspaceChangeService = new WorkspaceChangeService(this.workspaceState);
|
|
327
333
|
workspaceWorkbenchService;
|
|
328
334
|
database;
|
|
@@ -518,7 +524,7 @@ export class NodeConnector {
|
|
|
518
524
|
options.fakeRunner === undefined ? undefined : new FakeTestRunner(options.fakeRunner);
|
|
519
525
|
this.fakeScript = options.fakeScript;
|
|
520
526
|
this.runAttachmentService = new RunAttachmentService((session) => this.runners.require(session.runner).nativeImageRoots(session), (sessionId) => this.runtime.activeSessionRun(sessionId) !== undefined ||
|
|
521
|
-
this.runtime.sessionQueue(sessionId).itemIds.length > 0);
|
|
527
|
+
this.runtime.sessionQueue(sessionId).itemIds.length > 0, () => this.attachmentTemporaryRoot());
|
|
522
528
|
this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory, options.codexRunClientFactory);
|
|
523
529
|
this.codexRunController = new CodexManagedRunController(this.codexClient, {
|
|
524
530
|
available: () => this.capabilities.codex.available,
|
|
@@ -615,7 +621,15 @@ export class NodeConnector {
|
|
|
615
621
|
intercepted: () => this.fakeRunner !== undefined,
|
|
616
622
|
active: this.runState.active,
|
|
617
623
|
parseAttachments: parseRunnerImageAttachments,
|
|
618
|
-
persistAttachments: (
|
|
624
|
+
persistAttachments: async (_cwd, runId, attachments) => {
|
|
625
|
+
const run = this.runtime.getRun(runId);
|
|
626
|
+
if (run === undefined)
|
|
627
|
+
throw new Error('RUN_NOT_FOUND');
|
|
628
|
+
const storageRunId = `${runId}-opencode-images`;
|
|
629
|
+
const paths = await this.runAttachmentService.persistFiles(run.workspaceId, storageRunId, attachments);
|
|
630
|
+
this.runAttachmentService.retainSessionFiles(run.sessionId, storageRunId);
|
|
631
|
+
return paths;
|
|
632
|
+
},
|
|
619
633
|
emit: (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status),
|
|
620
634
|
emitItem: (runId, item) => this.emitConversationItem(runId, item),
|
|
621
635
|
adopt: (input) => this.runtime.adoptRun(input),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseComposerTags } from '@myagentroam/protocol';
|
|
1
2
|
import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
|
|
2
3
|
import { parseSecretEnvironment } from '../../util/secret-environment.js';
|
|
3
4
|
import { runnerErrorProjection } from '../../util/runner-error.js';
|
|
@@ -264,9 +265,18 @@ export class OpenCodeManagedRunController {
|
|
|
264
265
|
this.host.emit(input.runId, 'run.running', {}, 'RUNNING');
|
|
265
266
|
// The initial SSE snapshot can contain an idle event from before this prompt.
|
|
266
267
|
this.pendingIdle.delete(input.runId);
|
|
268
|
+
// Replacement inputs can retain earlier tags; the latest tag describes the current images.
|
|
269
|
+
const currentAttachments = parseComposerTags(input.input)
|
|
270
|
+
.filter((part) => part.kind === 'mar_attachments')
|
|
271
|
+
.at(-1);
|
|
272
|
+
const existingImagePaths = currentAttachments?.payload.files
|
|
273
|
+
.filter((file) => input.attachments.some((image) => image.name === file.name && image.mime === file.mime))
|
|
274
|
+
.map((file) => file.path) ?? [];
|
|
267
275
|
const attachmentPaths = input.attachments.length === 0
|
|
268
276
|
? []
|
|
269
|
-
:
|
|
277
|
+
: existingImagePaths.length === input.attachments.length
|
|
278
|
+
? existingImagePaths
|
|
279
|
+
: await this.host.persistAttachments(input.cwd, input.runId, input.attachments);
|
|
270
280
|
const execution = this.runner.execution.get(input.runId);
|
|
271
281
|
if (execution !== undefined) {
|
|
272
282
|
execution.responseLatency = new ResponseLatencyTracker();
|
|
@@ -660,7 +670,7 @@ function openCodeImageInput(input, attachmentPaths) {
|
|
|
660
670
|
if (attachmentPaths.length === 0)
|
|
661
671
|
return input;
|
|
662
672
|
const paths = attachmentPaths.map((path) => `- ${JSON.stringify(path)}`).join('\n');
|
|
663
|
-
return `${input}\n\nIf an inline image in this message is unavailable, use the read tool on the corresponding
|
|
673
|
+
return `${input}\n\nIf an inline image in this message is unavailable, use the read tool on the corresponding file below. Do not read it again when the inline image is already visible:\n${paths}`;
|
|
664
674
|
}
|
|
665
675
|
function hasMcpConfiguration(value) {
|
|
666
676
|
return value !== null && typeof value === 'object' && Object.keys(value).length > 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
@@ -11,11 +11,16 @@ const SESSION_ATTACHMENT_IDLE_TTL_MS = 30 * 60_000;
|
|
|
11
11
|
export class RunAttachmentService {
|
|
12
12
|
nativeImageRoots;
|
|
13
13
|
sessionIsBusy;
|
|
14
|
+
temporaryRoot;
|
|
14
15
|
state = new AttachmentDomainState();
|
|
15
16
|
disposed = false;
|
|
16
|
-
|
|
17
|
+
fileResources = new Map();
|
|
18
|
+
constructor(nativeImageRoots, sessionIsBusy = () => false, temporaryRoot = () => {
|
|
19
|
+
throw new Error('NODE_CONFIG_UNAVAILABLE');
|
|
20
|
+
}) {
|
|
17
21
|
this.nativeImageRoots = nativeImageRoots;
|
|
18
22
|
this.sessionIsBusy = sessionIsBusy;
|
|
23
|
+
this.temporaryRoot = temporaryRoot;
|
|
19
24
|
}
|
|
20
25
|
protectSession(sessionId) {
|
|
21
26
|
this.assertOpen();
|
|
@@ -51,6 +56,25 @@ export class RunAttachmentService {
|
|
|
51
56
|
this.state.runFileSessions.set(runId, sessionId);
|
|
52
57
|
this.refreshSession(sessionId);
|
|
53
58
|
}
|
|
59
|
+
adoptSessionFiles(sessionId, runId, directories, workspaceId, paths) {
|
|
60
|
+
this.assertOpen();
|
|
61
|
+
if (directories.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
this.state.runFileDirectories.set(runId, [...directories]);
|
|
64
|
+
this.registerFileResources(workspaceId, paths);
|
|
65
|
+
this.retainSessionFiles(sessionId, runId);
|
|
66
|
+
}
|
|
67
|
+
resourcePath(path) {
|
|
68
|
+
return `mar-attachment://${basename(dirname(path))}/${basename(path)}`;
|
|
69
|
+
}
|
|
70
|
+
fileResource(workspaceId, resourcePath) {
|
|
71
|
+
const resource = this.fileResources.get(resourcePath);
|
|
72
|
+
return resource?.workspaceId === workspaceId ? resource.path : undefined;
|
|
73
|
+
}
|
|
74
|
+
registerFileResources(workspaceId, paths) {
|
|
75
|
+
for (const path of paths)
|
|
76
|
+
this.fileResources.set(this.resourcePath(path), { workspaceId, path });
|
|
77
|
+
}
|
|
54
78
|
refreshSession(sessionId) {
|
|
55
79
|
if (this.disposed)
|
|
56
80
|
return;
|
|
@@ -106,16 +130,20 @@ export class RunAttachmentService {
|
|
|
106
130
|
this.refreshSession(nextId);
|
|
107
131
|
}
|
|
108
132
|
async discardFiles(runId) {
|
|
109
|
-
const
|
|
133
|
+
const directories = this.state.runFileDirectories.get(runId);
|
|
110
134
|
this.state.runFileDirectories.delete(runId);
|
|
135
|
+
if (directories !== undefined)
|
|
136
|
+
for (const [resourcePath, resource] of this.fileResources)
|
|
137
|
+
if (directories.includes(dirname(resource.path)))
|
|
138
|
+
this.fileResources.delete(resourcePath);
|
|
111
139
|
const sessionId = this.state.runFileSessions.get(runId);
|
|
112
140
|
this.state.runFileSessions.delete(runId);
|
|
113
141
|
if (sessionId !== undefined) {
|
|
114
142
|
this.state.sessionRetentions.get(sessionId)?.runs.delete(runId);
|
|
115
143
|
this.refreshSession(sessionId);
|
|
116
144
|
}
|
|
117
|
-
if (
|
|
118
|
-
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
145
|
+
if (directories !== undefined)
|
|
146
|
+
await Promise.all(directories.map((directory) => rm(directory, { recursive: true, force: true }).catch(() => undefined)));
|
|
119
147
|
}
|
|
120
148
|
async releaseSession(sessionId) {
|
|
121
149
|
const retained = this.state.sessionRetentions.get(sessionId);
|
|
@@ -173,32 +201,48 @@ export class RunAttachmentService {
|
|
|
173
201
|
throw error;
|
|
174
202
|
}
|
|
175
203
|
}
|
|
176
|
-
async persistFiles(
|
|
204
|
+
async persistFiles(workspaceId, runId, attachments) {
|
|
177
205
|
if (attachments.length === 0)
|
|
178
206
|
return [];
|
|
179
207
|
this.assertOpen();
|
|
180
|
-
const
|
|
181
|
-
await mkdir(marDirectory, { recursive: true, mode: 0o700 });
|
|
182
|
-
const marMetadata = await lstat(marDirectory);
|
|
183
|
-
if (!marMetadata.isDirectory() || marMetadata.isSymbolicLink())
|
|
184
|
-
throw new Error('MESSAGE_ATTACHMENT_DIRECTORY_INVALID');
|
|
185
|
-
const filesDirectory = join(marDirectory, 'temp-files');
|
|
208
|
+
const filesDirectory = this.temporaryRoot();
|
|
186
209
|
await mkdir(filesDirectory, { recursive: true, mode: 0o700 });
|
|
187
210
|
const filesMetadata = await lstat(filesDirectory);
|
|
188
211
|
if (!filesMetadata.isDirectory() || filesMetadata.isSymbolicLink())
|
|
189
212
|
throw new Error('MESSAGE_ATTACHMENT_DIRECTORY_INVALID');
|
|
190
|
-
const
|
|
191
|
-
await mkdir(runDirectory, { mode: 0o700 });
|
|
192
|
-
this.state.runFileDirectories.set(runId, runDirectory);
|
|
213
|
+
const createdDirectories = [];
|
|
193
214
|
try {
|
|
215
|
+
const paths = [];
|
|
216
|
+
for (const attachment of attachments) {
|
|
217
|
+
this.assertOpen();
|
|
218
|
+
const directory = join(filesDirectory, randomUUID());
|
|
219
|
+
await mkdir(directory, { mode: 0o700 });
|
|
220
|
+
createdDirectories.push(directory);
|
|
221
|
+
this.state.runFileDirectories.set(runId, [
|
|
222
|
+
...(this.state.runFileDirectories.get(runId) ?? []),
|
|
223
|
+
directory
|
|
224
|
+
]);
|
|
225
|
+
const path = join(directory, attachment.name);
|
|
226
|
+
await writeFile(path, Buffer.from(attachment.dataBase64, 'base64'), {
|
|
227
|
+
flag: 'wx',
|
|
228
|
+
mode: 0o600
|
|
229
|
+
});
|
|
230
|
+
paths.push(path);
|
|
231
|
+
}
|
|
194
232
|
this.assertOpen();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return attachments.map((attachment) => `.mar/temp-files/${runId}/${attachment.name}`);
|
|
233
|
+
this.registerFileResources(workspaceId, paths);
|
|
234
|
+
return paths;
|
|
198
235
|
}
|
|
199
236
|
catch (error) {
|
|
200
|
-
this.state.runFileDirectories.
|
|
201
|
-
|
|
237
|
+
const directories = this.state.runFileDirectories.get(runId);
|
|
238
|
+
if (directories !== undefined) {
|
|
239
|
+
const retained = directories.filter((directory) => !createdDirectories.includes(directory));
|
|
240
|
+
if (retained.length === 0)
|
|
241
|
+
this.state.runFileDirectories.delete(runId);
|
|
242
|
+
else
|
|
243
|
+
this.state.runFileDirectories.set(runId, retained);
|
|
244
|
+
}
|
|
245
|
+
await Promise.all(createdDirectories.map((directory) => rm(directory, { recursive: true, force: true })));
|
|
202
246
|
throw error;
|
|
203
247
|
}
|
|
204
248
|
}
|
|
@@ -5,7 +5,7 @@ import { personalInstructionsForRunner } from '../util/personal-instructions.js'
|
|
|
5
5
|
import { safeErrorCode } from '../util/safe-error.js';
|
|
6
6
|
import { publicGoalStartText } from './goal-runtime.js';
|
|
7
7
|
import { serializeComposerTag, composerVisibleText } from '@myagentroam/protocol';
|
|
8
|
-
import { relative
|
|
8
|
+
import { relative } from 'node:path';
|
|
9
9
|
import { prepareComposerResourceInput, composerResourceInputTags, validateComposerSkills } from './composer-message-input.js';
|
|
10
10
|
const INTERNAL_GOAL = Symbol('internalGoal');
|
|
11
11
|
const INTERNAL_SCHEDULE = Symbol('internalSchedule');
|
|
@@ -148,24 +148,29 @@ export class SessionMessageService {
|
|
|
148
148
|
if (created.queueItem === undefined)
|
|
149
149
|
this.options.coordinator.publishCurrentTurn(created.run.id);
|
|
150
150
|
const prepare = async () => {
|
|
151
|
+
let reservation;
|
|
151
152
|
try {
|
|
152
153
|
const currentSession = this.options.runtime.getAgentSession(session.id) ?? session;
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
if (attachmentIds.length > 0)
|
|
155
|
+
reservation = await this.options.uploads.reserve(session.workspaceId, attachmentIds);
|
|
156
|
+
const attachments = reservation === undefined
|
|
155
157
|
? inlineAttachments
|
|
156
|
-
:
|
|
158
|
+
: reservation.attachments.map((attachment) => attachment.attachment);
|
|
157
159
|
const imageAttachments = attachments.filter(isImageAttachment);
|
|
158
160
|
const runnerImageAttachments = attachments.filter(isRunnerImageAttachment);
|
|
159
161
|
const workspacePath = this.options.workspacePath?.(currentSession.workspaceId) ?? currentSession.cwd;
|
|
160
162
|
await validateComposerSkills(runnerContent, workspacePath);
|
|
161
|
-
const attachmentPaths =
|
|
163
|
+
const attachmentPaths = reservation === undefined
|
|
164
|
+
? await this.persistAttachments(currentSession, submissionId, attachments)
|
|
165
|
+
: reservation.attachments.map((attachment) => attachment.path);
|
|
162
166
|
const attachmentFiles = attachments.map((attachment, index) => ({
|
|
163
167
|
name: attachment.name,
|
|
164
168
|
mime: attachment.mime,
|
|
165
|
-
path:
|
|
169
|
+
path: reservation?.attachments[index]?.resourcePath ??
|
|
170
|
+
this.options.attachments.resourcePath(attachmentPaths[index])
|
|
166
171
|
}));
|
|
167
|
-
const runnerFiles = attachmentFiles.map((file) => {
|
|
168
|
-
const path = relative(currentSession.cwd,
|
|
172
|
+
const runnerFiles = attachmentFiles.map((file, index) => {
|
|
173
|
+
const path = relative(currentSession.cwd, attachmentPaths[index]).replaceAll('\\', '/');
|
|
169
174
|
return path === file.path ? file : { ...file, path, workspacePath: file.path };
|
|
170
175
|
});
|
|
171
176
|
const displayInput = withNonImageAttachmentPrompt(publicContent, attachmentPaths, attachmentFiles);
|
|
@@ -228,11 +233,12 @@ export class SessionMessageService {
|
|
|
228
233
|
queueBefore: queueBeforeCreate,
|
|
229
234
|
...(replaceCurrentRun === undefined ? {} : { activeRunId: replaceCurrentRun.id })
|
|
230
235
|
});
|
|
236
|
+
reservation?.commit((directories) => this.options.attachments.adoptSessionFiles(currentSession.id, submissionId, directories, currentSession.workspaceId, attachmentPaths));
|
|
231
237
|
this.options.attachments.acceptSessionInput(currentSession.id);
|
|
232
|
-
void this.options.uploads.consume(attachmentIds).catch(() => undefined);
|
|
233
238
|
return delivery;
|
|
234
239
|
}
|
|
235
240
|
catch (error) {
|
|
241
|
+
reservation?.release();
|
|
236
242
|
if (goalMessage !== undefined)
|
|
237
243
|
this.options.goalPreparationFailed(session, goalMessage.goal, goalMessage.activated);
|
|
238
244
|
this.options.coordinator.failPreparation(submissionId, safeErrorCode(error, 'MESSAGE_PREPARATION_FAILED'));
|
|
@@ -263,7 +269,7 @@ export class SessionMessageService {
|
|
|
263
269
|
}
|
|
264
270
|
}
|
|
265
271
|
async persistAttachments(session, runId, attachments) {
|
|
266
|
-
return this.options.attachments.persistFiles(
|
|
272
|
+
return this.options.attachments.persistFiles(session.workspaceId, runId, attachments);
|
|
267
273
|
}
|
|
268
274
|
async channelMessage(input) {
|
|
269
275
|
if (typeof input.sessionId !== 'string' ||
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
2
|
-
import { realpath } from 'node:fs/promises';
|
|
1
|
+
import { basename, dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { isComposerAttachmentResourcePath } from '@myagentroam/protocol';
|
|
3
4
|
import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
|
|
4
5
|
const INDEX_CACHE_LIMIT = 32;
|
|
5
6
|
const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
|
|
@@ -9,13 +10,15 @@ export class WorkspaceFileService {
|
|
|
9
10
|
state;
|
|
10
11
|
onInvalidated;
|
|
11
12
|
indexLimits;
|
|
13
|
+
attachmentResource;
|
|
12
14
|
mutationTails = new Map();
|
|
13
15
|
indexQueue = [];
|
|
14
16
|
indexRunning = false;
|
|
15
|
-
constructor(state, onInvalidated, indexLimits = {}) {
|
|
17
|
+
constructor(state, onInvalidated, indexLimits = {}, attachmentResource = () => undefined) {
|
|
16
18
|
this.state = state;
|
|
17
19
|
this.onInvalidated = onInvalidated;
|
|
18
20
|
this.indexLimits = indexLimits;
|
|
21
|
+
this.attachmentResource = attachmentResource;
|
|
19
22
|
}
|
|
20
23
|
async list(workspace, input) {
|
|
21
24
|
if ((input.path !== undefined && typeof input.path !== 'string') ||
|
|
@@ -38,6 +41,13 @@ export class WorkspaceFileService {
|
|
|
38
41
|
throw new Error('FILE_RANGE_INVALID');
|
|
39
42
|
const offset = typeof input.offset === 'number' ? input.offset : 0;
|
|
40
43
|
const limit = typeof input.limit === 'number' ? input.limit : 512 * 1024;
|
|
44
|
+
if (input.path.startsWith('mar-attachment:')) {
|
|
45
|
+
const target = await this.resolveAttachmentResource(workspace.id, input.path);
|
|
46
|
+
return {
|
|
47
|
+
...(await readWorkspaceTextFile(dirname(target), basename(target), offset, limit)),
|
|
48
|
+
path: input.path
|
|
49
|
+
};
|
|
50
|
+
}
|
|
41
51
|
const path = normalizeFilePreviewPath(input.path);
|
|
42
52
|
if (workspacePath !== workspace.path && isFilePreviewAbsolutePath(path))
|
|
43
53
|
throw new Error('FILE_PATH_ESCAPE');
|
|
@@ -54,6 +64,15 @@ export class WorkspaceFileService {
|
|
|
54
64
|
input.limit < 1 ||
|
|
55
65
|
input.limit > 512 * 1024)))
|
|
56
66
|
throw new Error('FILE_RANGE_INVALID');
|
|
67
|
+
if (input.path.startsWith('mar-attachment:')) {
|
|
68
|
+
if (input.previewDirectory !== undefined)
|
|
69
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
70
|
+
const target = await this.resolveAttachmentResource(workspace.id, input.path);
|
|
71
|
+
return {
|
|
72
|
+
...(await readWorkspaceFileContentRange(dirname(target), basename(target), typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024)),
|
|
73
|
+
path: input.path
|
|
74
|
+
};
|
|
75
|
+
}
|
|
57
76
|
const path = normalizeFilePreviewPath(input.path);
|
|
58
77
|
if (workspacePath !== workspace.path && isFilePreviewAbsolutePath(path))
|
|
59
78
|
throw new Error('FILE_PATH_ESCAPE');
|
|
@@ -76,6 +95,23 @@ export class WorkspaceFileService {
|
|
|
76
95
|
? readAbsoluteFileContentRange(path, offset, limit)
|
|
77
96
|
: readWorkspaceFileContentRange(workspacePath, path, offset, limit);
|
|
78
97
|
}
|
|
98
|
+
async resolveAttachmentResource(workspaceId, path) {
|
|
99
|
+
if (!isComposerAttachmentResourcePath(path))
|
|
100
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
101
|
+
const target = this.attachmentResource(workspaceId, path);
|
|
102
|
+
if (target === undefined)
|
|
103
|
+
throw new Error('FILE_NOT_FOUND');
|
|
104
|
+
const [directory, file] = await Promise.all([lstat(dirname(target)), lstat(target)]).catch((error) => {
|
|
105
|
+
if (error.code === 'ENOENT')
|
|
106
|
+
throw new Error('FILE_NOT_FOUND');
|
|
107
|
+
throw error;
|
|
108
|
+
});
|
|
109
|
+
if (directory.isSymbolicLink() || file.isSymbolicLink())
|
|
110
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
111
|
+
if (!directory.isDirectory() || !file.isFile())
|
|
112
|
+
throw new Error('FILE_NOT_REGULAR');
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
79
115
|
readDirect(workspace, input, workspacePath = workspace.path) {
|
|
80
116
|
const readContent = this.readContent.bind(this);
|
|
81
117
|
return (async function* () {
|
|
@@ -147,6 +183,8 @@ export class WorkspaceFileService {
|
|
|
147
183
|
const path = input.path;
|
|
148
184
|
const content = input.content;
|
|
149
185
|
const expectedRevision = input.expectedRevision;
|
|
186
|
+
if (path.startsWith('mar-attachment:'))
|
|
187
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
150
188
|
return this.serializeMutation(workspace.id, async () => {
|
|
151
189
|
const result = await writeWorkspaceTextFile(workspace.path, path, content, expectedRevision);
|
|
152
190
|
this.dispose(workspace.id);
|
|
@@ -177,6 +215,8 @@ export class WorkspaceFileService {
|
|
|
177
215
|
throw new Error('FILE_MUTATION_INVALID');
|
|
178
216
|
const path = input.path;
|
|
179
217
|
const destination = typeof input.destination === 'string' ? input.destination : undefined;
|
|
218
|
+
if (path.startsWith('mar-attachment:') || destination?.startsWith('mar-attachment:'))
|
|
219
|
+
throw new Error('FILE_PATH_ESCAPE');
|
|
180
220
|
return this.serializeMutation(workspace.id, async () => {
|
|
181
221
|
const result = await mutateWorkspaceFile(workspace.path, action === 'MOVE' ? { action, path, destination: destination } : { action, path });
|
|
182
222
|
this.dispose(workspace.id);
|
|
@@ -9,11 +9,10 @@ const BLOCK_END = '# END MyAgentRoam';
|
|
|
9
9
|
const LEGACY_BLOCK_BEGIN = '# BEGIN MyAgentRoam Skills';
|
|
10
10
|
const LEGACY_BLOCK_END = '# END MyAgentRoam Skills';
|
|
11
11
|
export async function ensureWorkspaceGitExclude(workspace) {
|
|
12
|
-
await mutateGitExclude(workspace, (
|
|
12
|
+
await mutateGitExclude(workspace, () => undefined);
|
|
13
13
|
}
|
|
14
14
|
export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
|
|
15
15
|
await mutateGitExclude(workspace, (entries) => {
|
|
16
|
-
entries.add(MAR_TEMP_DIRECTORY);
|
|
17
16
|
for (const root of ['.agents', '.claude']) {
|
|
18
17
|
const line = `/${root}/skills/${name}/`;
|
|
19
18
|
if (add)
|
|
@@ -26,7 +25,6 @@ export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
|
|
|
26
25
|
export async function rewriteWorkspaceGitExclude(workspace, names) {
|
|
27
26
|
await mutateGitExclude(workspace, (entries) => {
|
|
28
27
|
entries.clear();
|
|
29
|
-
entries.add(MAR_TEMP_DIRECTORY);
|
|
30
28
|
for (const name of names)
|
|
31
29
|
for (const root of ['.agents', '.claude'])
|
|
32
30
|
entries.add(`/${root}/skills/${name}/`);
|
|
@@ -65,9 +63,13 @@ async function mutateGitExclude(workspace, mutate) {
|
|
|
65
63
|
const pattern = managedBlockPattern();
|
|
66
64
|
const existing = [...content.matchAll(pattern)].map((match) => match[0]).join('\n');
|
|
67
65
|
const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('/.')));
|
|
66
|
+
entries.delete(MAR_TEMP_DIRECTORY);
|
|
68
67
|
mutate(entries);
|
|
69
|
-
const block = `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
|
|
70
|
-
const
|
|
68
|
+
const block = entries.size === 0 ? '' : `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
|
|
69
|
+
const unmanaged = content.replace(pattern, '');
|
|
70
|
+
const next = block === '' ? unmanaged : unmanaged.replace(/\s*$/u, '\n') + block;
|
|
71
|
+
if (next === content)
|
|
72
|
+
return;
|
|
71
73
|
const staging = path.join(path.dirname(exclude), `.mar-git-exclude-${randomUUID()}.tmp`);
|
|
72
74
|
try {
|
|
73
75
|
await writeFile(staging, next, { encoding: 'utf8', flag: 'wx' });
|
|
@@ -3,16 +3,17 @@ import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { dirname, join, posix, resolve } from 'node:path';
|
|
4
4
|
import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
|
|
5
5
|
import { preflightWorkspaceUpload, workspaceUploadTemporaryName } from '../workspace.js';
|
|
6
|
-
import { ensureWorkspaceGitExclude } from './workspace-git-exclude-service.js';
|
|
7
6
|
const CHUNK_BYTES = 512 * 1024;
|
|
8
7
|
const TTL_MS = 60 * 60_000;
|
|
9
8
|
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
10
9
|
export class WorkspaceUploadService {
|
|
11
10
|
state;
|
|
12
11
|
onCompleted;
|
|
13
|
-
|
|
12
|
+
temporaryRoot;
|
|
13
|
+
constructor(state, onCompleted, temporaryRoot) {
|
|
14
14
|
this.state = state;
|
|
15
15
|
this.onCompleted = onCompleted;
|
|
16
|
+
this.temporaryRoot = temporaryRoot;
|
|
16
17
|
}
|
|
17
18
|
async preflight(workspace, input) {
|
|
18
19
|
if ((input.directory !== undefined && typeof input.directory !== 'string') ||
|
|
@@ -47,7 +48,7 @@ export class WorkspaceUploadService {
|
|
|
47
48
|
throw new Error('COMPOSER_ATTACHMENT_INVALID');
|
|
48
49
|
const target = attachment === undefined
|
|
49
50
|
? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name)
|
|
50
|
-
: await this.attachmentTarget(
|
|
51
|
+
: await this.attachmentTarget(attachment.id, input.name);
|
|
51
52
|
if (target.status === 'INVALID_NAME')
|
|
52
53
|
throw new Error('UPLOAD_NAME_INVALID');
|
|
53
54
|
if (target.status === 'EXISTS_DIRECTORY')
|
|
@@ -164,14 +165,7 @@ export class WorkspaceUploadService {
|
|
|
164
165
|
this.state.uploads.delete(upload.id);
|
|
165
166
|
this.onCompleted(upload.workspaceId);
|
|
166
167
|
if (upload.composerAttachment !== undefined) {
|
|
167
|
-
const
|
|
168
|
-
const timer = setTimeout(() => {
|
|
169
|
-
const attachment = this.state.composerAttachments.get(attachmentId);
|
|
170
|
-
if (attachment !== undefined)
|
|
171
|
-
void this.removeCompleted(attachment);
|
|
172
|
-
}, TTL_MS);
|
|
173
|
-
timer.unref();
|
|
174
|
-
this.state.composerAttachments.set(attachmentId, {
|
|
168
|
+
const attachment = {
|
|
175
169
|
id: upload.composerAttachment.id,
|
|
176
170
|
workspaceId: upload.workspaceId,
|
|
177
171
|
path: upload.path,
|
|
@@ -179,8 +173,12 @@ export class WorkspaceUploadService {
|
|
|
179
173
|
name: upload.composerAttachment.name,
|
|
180
174
|
mime: upload.composerAttachment.mime,
|
|
181
175
|
size: upload.size,
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
expiresAt: Date.now() + TTL_MS,
|
|
177
|
+
reserved: false,
|
|
178
|
+
timer: undefined
|
|
179
|
+
};
|
|
180
|
+
this.state.composerAttachments.set(attachment.id, attachment);
|
|
181
|
+
this.scheduleAttachmentExpiry(attachment);
|
|
184
182
|
}
|
|
185
183
|
return {
|
|
186
184
|
uploadId: upload.id,
|
|
@@ -198,36 +196,77 @@ export class WorkspaceUploadService {
|
|
|
198
196
|
const attachment = this.state.composerAttachments.get(uploadId);
|
|
199
197
|
if (attachment === undefined)
|
|
200
198
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
199
|
+
if (attachment.reserved)
|
|
200
|
+
throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
|
|
201
201
|
await this.removeCompleted(attachment);
|
|
202
202
|
}
|
|
203
|
-
async
|
|
204
|
-
|
|
203
|
+
async reserve(workspaceId, ids) {
|
|
204
|
+
if (new Set(ids).size !== ids.length)
|
|
205
|
+
throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
|
|
206
|
+
const storedAttachments = ids.map((id) => {
|
|
205
207
|
const stored = this.state.composerAttachments.get(id);
|
|
206
|
-
if (stored === undefined || stored.workspaceId !== workspaceId)
|
|
208
|
+
if (stored === undefined || stored.workspaceId !== workspaceId || stored.reserved)
|
|
207
209
|
throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
return stored;
|
|
211
|
+
});
|
|
212
|
+
for (const stored of storedAttachments) {
|
|
213
|
+
stored.reserved = true;
|
|
214
|
+
clearTimeout(stored.timer);
|
|
215
|
+
stored.timer = undefined;
|
|
216
|
+
}
|
|
217
|
+
let active = true;
|
|
218
|
+
const assertReserved = () => {
|
|
219
|
+
if (!active ||
|
|
220
|
+
storedAttachments.some((stored) => this.state.composerAttachments.get(stored.id) !== stored))
|
|
213
221
|
throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
|
|
214
|
-
|
|
222
|
+
};
|
|
223
|
+
const release = () => {
|
|
224
|
+
if (!active)
|
|
225
|
+
return;
|
|
226
|
+
active = false;
|
|
227
|
+
for (const stored of storedAttachments) {
|
|
228
|
+
if (this.state.composerAttachments.get(stored.id) !== stored)
|
|
229
|
+
continue;
|
|
230
|
+
stored.reserved = false;
|
|
231
|
+
this.scheduleAttachmentExpiry(stored);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
const attachments = await Promise.all(storedAttachments.map(async (stored) => {
|
|
236
|
+
const metadata = await lstat(stored.targetPath).catch(() => undefined);
|
|
237
|
+
if (metadata === undefined ||
|
|
238
|
+
metadata.isSymbolicLink() ||
|
|
239
|
+
!metadata.isFile() ||
|
|
240
|
+
metadata.size !== stored.size)
|
|
241
|
+
throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
|
|
242
|
+
const bytes = await readFile(stored.targetPath);
|
|
243
|
+
return {
|
|
244
|
+
attachment: {
|
|
245
|
+
name: stored.name,
|
|
246
|
+
mime: stored.mime,
|
|
247
|
+
dataBase64: bytes.toString('base64')
|
|
248
|
+
},
|
|
249
|
+
path: stored.targetPath,
|
|
250
|
+
resourcePath: stored.path
|
|
251
|
+
};
|
|
252
|
+
}));
|
|
253
|
+
assertReserved();
|
|
215
254
|
return {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
255
|
+
attachments,
|
|
256
|
+
commit: (retain) => {
|
|
257
|
+
assertReserved();
|
|
258
|
+
retain(storedAttachments.map((stored) => dirname(stored.targetPath)));
|
|
259
|
+
for (const stored of storedAttachments)
|
|
260
|
+
this.state.composerAttachments.delete(stored.id);
|
|
261
|
+
active = false;
|
|
220
262
|
},
|
|
221
|
-
|
|
263
|
+
release
|
|
222
264
|
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (attachment !== undefined)
|
|
229
|
-
await this.removeCompleted(attachment);
|
|
230
|
-
}));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
release();
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
231
270
|
}
|
|
232
271
|
async shutdown() {
|
|
233
272
|
await Promise.allSettled([...this.state.uploads.values()].map((upload) => this.remove(upload)));
|
|
@@ -235,6 +274,12 @@ export class WorkspaceUploadService {
|
|
|
235
274
|
await this.removeCompleted(attachment);
|
|
236
275
|
this.state.composerAttachments.clear();
|
|
237
276
|
}
|
|
277
|
+
fileResource(workspaceId, resourcePath) {
|
|
278
|
+
for (const attachment of this.state.composerAttachments.values())
|
|
279
|
+
if (attachment.workspaceId === workspaceId && attachment.path === resourcePath)
|
|
280
|
+
return attachment.targetPath;
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
238
283
|
require(id) {
|
|
239
284
|
if (typeof id !== 'string')
|
|
240
285
|
throw new Error('UPLOAD_NOT_FOUND');
|
|
@@ -258,12 +303,15 @@ export class WorkspaceUploadService {
|
|
|
258
303
|
clearTimeout(attachment.timer);
|
|
259
304
|
await rm(dirname(attachment.targetPath), { recursive: true, force: true }).catch(() => undefined);
|
|
260
305
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
306
|
+
scheduleAttachmentExpiry(attachment) {
|
|
307
|
+
attachment.timer = setTimeout(() => {
|
|
308
|
+
if (!attachment.reserved)
|
|
309
|
+
void this.removeCompleted(attachment);
|
|
310
|
+
}, Math.max(0, attachment.expiresAt - Date.now()));
|
|
311
|
+
attachment.timer.unref();
|
|
312
|
+
}
|
|
313
|
+
async attachmentTarget(id, name) {
|
|
314
|
+
const temp = this.temporaryRoot();
|
|
267
315
|
await mkdir(temp, { recursive: true, mode: 0o700 });
|
|
268
316
|
await safeDirectory(temp);
|
|
269
317
|
const directory = join(temp, id);
|
|
@@ -277,7 +325,7 @@ export class WorkspaceUploadService {
|
|
|
277
325
|
}
|
|
278
326
|
await safeDirectory(directory);
|
|
279
327
|
return {
|
|
280
|
-
path:
|
|
328
|
+
path: `mar-attachment://${id}/${name}`,
|
|
281
329
|
absolutePath: join(directory, name),
|
|
282
330
|
status: 'READY'
|
|
283
331
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0 AND GPL-2.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"playwright": "1.62.1",
|
|
38
38
|
"ws": "^8.21.3",
|
|
39
39
|
"zod": "4.4.3",
|
|
40
|
-
"@myagentroam/agent": "0.10.
|
|
40
|
+
"@myagentroam/agent": "0.10.4",
|
|
41
41
|
"@myagentroam/code-navigation": "0.10.1",
|
|
42
42
|
"@myagentroam/marctl": "0.10.1",
|
|
43
|
-
"@myagentroam/protocol": "0.10.
|
|
43
|
+
"@myagentroam/protocol": "0.10.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/ws": "^8.18.1"
|