@myagentroam/node 0.11.25 → 0.11.26
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/codex-app-server.js +2 -2
- package/dist/connector.js +1 -1
- package/dist/runner/abstract-runner.js +2 -6
- package/dist/runner/claude-code-runner.js +3 -1
- package/dist/runner/codex/conversation-parser.js +11 -1
- package/dist/runner/codex-runner.js +52 -29
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +3 -3
- package/dist/runner/mar-agent-runner.js +35 -5
- package/dist/runner/opencode-runner.js +6 -2
- package/dist/runtime-state.js +6 -2
- package/dist/service/hosted-conversation-node-service.js +32 -6
- package/dist/service/image-preview-service.js +100 -0
- package/dist/service/native-session-projection-service.js +8 -5
- package/dist/service/recent-run-projection-service.js +3 -0
- package/dist/service/run-workbench-service.js +6 -1
- package/dist/service/session-catalog-service.js +91 -23
- package/dist/service/session-rollback-target.js +22 -0
- package/dist/service/workspace-file-service.js +23 -1
- package/dist/service/workspace-session-cleanup-budget.js +32 -0
- package/dist/service/workspace-workbench-service.js +3 -0
- package/package.json +5 -4
package/dist/codex-app-server.js
CHANGED
|
@@ -235,8 +235,8 @@ export class CodexAppServerClient {
|
|
|
235
235
|
...codexThreadConfiguration(cwd, configuration)
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
|
-
listThreads() {
|
|
239
|
-
return this.request('thread/list',
|
|
238
|
+
listThreads(params = {}) {
|
|
239
|
+
return this.request('thread/list', params);
|
|
240
240
|
}
|
|
241
241
|
readThread(threadId) {
|
|
242
242
|
return this.request('thread/read', { threadId, includeTurns: false });
|
package/dist/connector.js
CHANGED
|
@@ -1079,7 +1079,7 @@ export class NodeConnector {
|
|
|
1079
1079
|
cachedNative: () => [...this.sessionState.directNative.values()],
|
|
1080
1080
|
cachedWorkspace: (workspaceId) => this.workspaceCoordinator.cachedSessions(workspaceId),
|
|
1081
1081
|
discoveryPending: (workspaceId) => this.workspaceSessionService.discoveryPending(workspaceId),
|
|
1082
|
-
discoverWorkspace: (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId),
|
|
1082
|
+
discoverWorkspace: (workspaceId, cleanup) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId, undefined, cleanup),
|
|
1083
1083
|
present: (session) => this.sessionPresentationService.present(session),
|
|
1084
1084
|
nativeIds: (session) => this.nativeSessionProjectionService.nativeIds(session),
|
|
1085
1085
|
forget: (session, nativeIds) => {
|
|
@@ -76,10 +76,6 @@ export class AbstractRunner {
|
|
|
76
76
|
...(input.pinned === undefined ? {} : { pinned: input.pinned })
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
|
-
async isNativeSessionMissing(session) {
|
|
80
|
-
void session;
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
79
|
async removeNative(session, nativeSessionId) {
|
|
84
80
|
void [session, nativeSessionId];
|
|
85
81
|
return false;
|
|
@@ -92,8 +88,8 @@ export class AbstractRunner {
|
|
|
92
88
|
void session;
|
|
93
89
|
return false;
|
|
94
90
|
}
|
|
95
|
-
async rollbackNative(session, boundary, targetTurnBoundary) {
|
|
96
|
-
void [session, boundary, targetTurnBoundary];
|
|
91
|
+
async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
|
|
92
|
+
void [session, boundary, targetTurnBoundary, targetClientMessageId];
|
|
97
93
|
throw new RunnerCapabilityError(this.name, 'session.rollback');
|
|
98
94
|
}
|
|
99
95
|
async clearNative(session) {
|
|
@@ -128,7 +128,9 @@ export class ClaudeCodeRunner extends AbstractRunner {
|
|
|
128
128
|
try {
|
|
129
129
|
official = await this.listSessions(target.path);
|
|
130
130
|
}
|
|
131
|
-
catch {
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (context.cleanup)
|
|
133
|
+
throw error;
|
|
132
134
|
// Native transcripts remain authoritative when SDK listing is unavailable.
|
|
133
135
|
}
|
|
134
136
|
const native = await discoverNativeSessions(this.name, target.path);
|
|
@@ -1376,7 +1376,17 @@ export function extractCodexThreads(result) {
|
|
|
1376
1376
|
const preview = typeof value.preview === 'string' ? value.preview.trim() : '';
|
|
1377
1377
|
const title = name || preview || undefined;
|
|
1378
1378
|
const titleOrigin = name ? 'OFFICIAL' : preview ? 'PREVIEW' : undefined;
|
|
1379
|
-
return [
|
|
1379
|
+
return [
|
|
1380
|
+
{
|
|
1381
|
+
id: value.id,
|
|
1382
|
+
source,
|
|
1383
|
+
derived,
|
|
1384
|
+
cwd: value.cwd,
|
|
1385
|
+
title,
|
|
1386
|
+
titleOrigin,
|
|
1387
|
+
lastActivityAt: codexTimestamp(value.updatedAt) ?? 0
|
|
1388
|
+
}
|
|
1389
|
+
];
|
|
1380
1390
|
});
|
|
1381
1391
|
}
|
|
1382
1392
|
export function codexThreadCwd(value) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { withMarctlGuide } from '../mcp/marctl-guide.js';
|
|
2
|
-
import { CodexAppServerClient,
|
|
2
|
+
import { CodexAppServerClient, isCodexRolloutMissingError } from '../codex-app-server.js';
|
|
3
3
|
import { AbstractRunner } from './abstract-runner.js';
|
|
4
4
|
import { declaredRunnerProfiles } from '../runner-profiles.js';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
@@ -280,22 +280,6 @@ export class CodexRunner extends AbstractRunner {
|
|
|
280
280
|
}
|
|
281
281
|
return input.title;
|
|
282
282
|
}
|
|
283
|
-
async isNativeSessionMissing(session) {
|
|
284
|
-
if (session.externalSessionId === null)
|
|
285
|
-
return false;
|
|
286
|
-
await this.start();
|
|
287
|
-
try {
|
|
288
|
-
await this.client.readThread(session.externalSessionId);
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
catch (error) {
|
|
292
|
-
if (error instanceof CodexAppServerRpcError &&
|
|
293
|
-
error.code === -32600 &&
|
|
294
|
-
/^no rollout found for thread id\b/i.test(error.rpcMessage))
|
|
295
|
-
return true;
|
|
296
|
-
throw error;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
283
|
async readConversationHistory(readers) {
|
|
300
284
|
const page = await readers.official();
|
|
301
285
|
return page === undefined ? undefined : { ...page, source: 'official' };
|
|
@@ -422,16 +406,50 @@ export class CodexRunner extends AbstractRunner {
|
|
|
422
406
|
return { failureCode: 'RUNNER_RESUME_FAILED' };
|
|
423
407
|
}
|
|
424
408
|
}
|
|
409
|
+
async cleanupThreads() {
|
|
410
|
+
await this.start();
|
|
411
|
+
const threads = [];
|
|
412
|
+
const cursors = new Set();
|
|
413
|
+
let cursor;
|
|
414
|
+
do {
|
|
415
|
+
const result = await this.client.listThreads({
|
|
416
|
+
limit: 200,
|
|
417
|
+
...(cursor === undefined ? {} : { cursor })
|
|
418
|
+
});
|
|
419
|
+
if (!isPlainRecord(result) ||
|
|
420
|
+
!Array.isArray(result.data) ||
|
|
421
|
+
result.data.some((entry) => !isPlainRecord(entry) ||
|
|
422
|
+
typeof entry.id !== 'string' ||
|
|
423
|
+
entry.id.length === 0 ||
|
|
424
|
+
typeof entry.cwd !== 'string' ||
|
|
425
|
+
entry.cwd.length === 0))
|
|
426
|
+
throw new Error('CODEX_THREAD_LIST_INVALID');
|
|
427
|
+
threads.push(...extractCodexThreads(result));
|
|
428
|
+
const next = result.nextCursor;
|
|
429
|
+
if (next === null || next === undefined)
|
|
430
|
+
return threads;
|
|
431
|
+
if (typeof next !== 'string' || next.length === 0 || cursors.has(next))
|
|
432
|
+
throw new Error('CODEX_THREAD_LIST_INVALID');
|
|
433
|
+
cursors.add(next);
|
|
434
|
+
cursor = next;
|
|
435
|
+
} while (true);
|
|
436
|
+
}
|
|
425
437
|
async discoverSessions(context, workspace) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
await
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
438
|
+
const [threads, nativeSessions] = context.cleanup
|
|
439
|
+
? [await this.cleanupThreads(), []]
|
|
440
|
+
: await Promise.all([
|
|
441
|
+
(async () => {
|
|
442
|
+
try {
|
|
443
|
+
await this.start();
|
|
444
|
+
return extractCodexThreads(await this.listThreads());
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Native transcripts remain a valid read-only discovery source.
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
})(),
|
|
451
|
+
discoverAllNativeSessions(this.name)
|
|
452
|
+
]);
|
|
435
453
|
const sessions = [];
|
|
436
454
|
for (const thread of threads) {
|
|
437
455
|
if (thread.derived || (thread.source !== 'cli' && thread.source !== 'vscode'))
|
|
@@ -440,7 +458,9 @@ export class CodexRunner extends AbstractRunner {
|
|
|
440
458
|
try {
|
|
441
459
|
cwd = codexThreadCwd(thread.cwd);
|
|
442
460
|
}
|
|
443
|
-
catch {
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (context.cleanup)
|
|
463
|
+
throw error;
|
|
444
464
|
continue;
|
|
445
465
|
}
|
|
446
466
|
const target = workspace ?? context.workspaces.find((candidate) => isWithinWorkspace(cwd, candidate.path));
|
|
@@ -451,11 +471,12 @@ export class CodexRunner extends AbstractRunner {
|
|
|
451
471
|
runner: this.name,
|
|
452
472
|
externalSessionId: thread.id,
|
|
453
473
|
cwd,
|
|
474
|
+
...(context.cleanup ? { lastActivityAt: thread.lastActivityAt } : {}),
|
|
454
475
|
...(thread.title === undefined ? {} : { title: thread.title }),
|
|
455
476
|
...(thread.titleOrigin === undefined ? {} : { titleOrigin: thread.titleOrigin })
|
|
456
477
|
}));
|
|
457
478
|
}
|
|
458
|
-
for (const entry of
|
|
479
|
+
for (const entry of nativeSessions) {
|
|
459
480
|
const target = workspace ??
|
|
460
481
|
context.workspaces.find((candidate) => isWithinWorkspace(entry.cwd, candidate.path));
|
|
461
482
|
if (target === undefined || !isWithinWorkspace(entry.cwd, target.path))
|
|
@@ -707,8 +728,10 @@ export class CodexRunner extends AbstractRunner {
|
|
|
707
728
|
return (session.externalSessionId !== null &&
|
|
708
729
|
this.threadHistoryModes.get(session.externalSessionId) === true);
|
|
709
730
|
}
|
|
710
|
-
async rollbackNative(session, boundary, targetTurnBoundary) {
|
|
731
|
+
async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
|
|
711
732
|
void boundary;
|
|
733
|
+
if (targetClientMessageId !== undefined)
|
|
734
|
+
throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
|
|
712
735
|
if (!this.supportsRollback(session) || targetTurnBoundary === undefined)
|
|
713
736
|
throw new Error('SESSION_ROLLBACK_UNAVAILABLE');
|
|
714
737
|
const threadId = session.externalSessionId;
|
|
@@ -114,9 +114,9 @@ export class MarAgentHostedConversationRuntime {
|
|
|
114
114
|
await this.releaseSession(input);
|
|
115
115
|
const catalog = await this.#openCatalog({ homeDirectory: input.homeDirectory });
|
|
116
116
|
try {
|
|
117
|
-
const rolledBack = await catalog.rollback(input.sessionId,
|
|
118
|
-
boundaryTurnId: input.boundaryTurnId
|
|
119
|
-
|
|
117
|
+
const rolledBack = await catalog.rollback(input.sessionId, input.beforeInput === undefined
|
|
118
|
+
? { boundaryTurnId: input.boundaryTurnId }
|
|
119
|
+
: { beforeInput: input.beforeInput });
|
|
120
120
|
return { lastActivityAt: Date.parse(rolledBack.lastActivityAt) };
|
|
121
121
|
}
|
|
122
122
|
catch (error) {
|
|
@@ -271,6 +271,30 @@ export class MarAgentRunner extends AbstractRunner {
|
|
|
271
271
|
catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
|
|
272
272
|
const activeCatalog = catalog;
|
|
273
273
|
const listed = await activeCatalog.list({ limit: 200 });
|
|
274
|
+
if (context.cleanup) {
|
|
275
|
+
const sessions = [...listed.sessions];
|
|
276
|
+
let cursor = listed.nextCursor;
|
|
277
|
+
while (cursor !== undefined) {
|
|
278
|
+
const next = await activeCatalog.list({ limit: 200, cursor });
|
|
279
|
+
sessions.push(...next.sessions);
|
|
280
|
+
cursor = next.nextCursor;
|
|
281
|
+
}
|
|
282
|
+
const workspaces = workspace ? [workspace] : context.workspaces;
|
|
283
|
+
return sessions.flatMap((session) => {
|
|
284
|
+
const target = workspaces.find((candidate) => resolve(candidate.path) === resolve(session.workspace));
|
|
285
|
+
return target
|
|
286
|
+
? [
|
|
287
|
+
context.createProjection({
|
|
288
|
+
workspace: target,
|
|
289
|
+
runner: this.name,
|
|
290
|
+
externalSessionId: session.id,
|
|
291
|
+
cwd: session.workspace,
|
|
292
|
+
lastActivityAt: Date.parse(session.lastActivityAt)
|
|
293
|
+
})
|
|
294
|
+
]
|
|
295
|
+
: [];
|
|
296
|
+
});
|
|
297
|
+
}
|
|
274
298
|
const workspaces = workspace ? [workspace] : context.workspaces;
|
|
275
299
|
return (await Promise.all(listed.sessions.map(async (session) => {
|
|
276
300
|
const target = workspaces.find((candidate) => resolve(candidate.path) === resolve(session.workspace));
|
|
@@ -300,7 +324,9 @@ export class MarAgentRunner extends AbstractRunner {
|
|
|
300
324
|
];
|
|
301
325
|
}))).flat();
|
|
302
326
|
}
|
|
303
|
-
catch {
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (context.cleanup)
|
|
329
|
+
throw error;
|
|
304
330
|
return [];
|
|
305
331
|
}
|
|
306
332
|
finally {
|
|
@@ -324,15 +350,19 @@ export class MarAgentRunner extends AbstractRunner {
|
|
|
324
350
|
await catalog.close().catch(() => undefined);
|
|
325
351
|
}
|
|
326
352
|
}
|
|
327
|
-
async rollbackNative(session, boundary) {
|
|
353
|
+
async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
|
|
328
354
|
if (session.externalSessionId === null)
|
|
329
355
|
throw new Error('SESSION_ROLLBACK_UNAVAILABLE');
|
|
330
356
|
await this.#releaseSessionResources?.(session.externalSessionId);
|
|
331
357
|
const catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
|
|
332
358
|
try {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
359
|
+
if (targetClientMessageId !== undefined && targetTurnBoundary === undefined)
|
|
360
|
+
throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
|
|
361
|
+
const rolledBack = await catalog.rollback(session.externalSessionId, targetClientMessageId === undefined
|
|
362
|
+
? { boundaryTurnId: boundary }
|
|
363
|
+
: {
|
|
364
|
+
beforeInput: { turnId: targetTurnBoundary, clientMessageId: targetClientMessageId }
|
|
365
|
+
});
|
|
336
366
|
return Date.parse(rolledBack.lastActivityAt);
|
|
337
367
|
}
|
|
338
368
|
catch (error) {
|
|
@@ -200,7 +200,9 @@ export class OpenCodeRunner extends AbstractRunner {
|
|
|
200
200
|
try {
|
|
201
201
|
sessions = await this.client.sessions(target.path);
|
|
202
202
|
}
|
|
203
|
-
catch {
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (context.cleanup)
|
|
205
|
+
throw error;
|
|
204
206
|
continue;
|
|
205
207
|
}
|
|
206
208
|
for (const value of sessions) {
|
|
@@ -209,7 +211,9 @@ export class OpenCodeRunner extends AbstractRunner {
|
|
|
209
211
|
session.parentId !== undefined ||
|
|
210
212
|
!isWithinWorkspace(session.directory, target.path))
|
|
211
213
|
continue;
|
|
212
|
-
const title =
|
|
214
|
+
const title = context.cleanup
|
|
215
|
+
? { title: session.title, titleOrigin: 'OFFICIAL' }
|
|
216
|
+
: await this.sessionTitle(session);
|
|
213
217
|
result.push(context.createProjection({
|
|
214
218
|
workspace: target,
|
|
215
219
|
runner: this.name,
|
package/dist/runtime-state.js
CHANGED
|
@@ -765,6 +765,9 @@ export class NodeRuntimeState {
|
|
|
765
765
|
const value = {
|
|
766
766
|
...current,
|
|
767
767
|
status: next,
|
|
768
|
+
...(next === 'INTERRUPTED' && current.status === 'CANCELLING'
|
|
769
|
+
? { interruptionReason: 'USER_STOP' }
|
|
770
|
+
: {}),
|
|
768
771
|
version: current.version + 1,
|
|
769
772
|
completedAt: terminal(next) ? now : current.completedAt,
|
|
770
773
|
updatedAt: now
|
|
@@ -795,7 +798,7 @@ export class NodeRuntimeState {
|
|
|
795
798
|
appendRunEvent(input) {
|
|
796
799
|
const execution = this.executions.get(input.runId);
|
|
797
800
|
const runId = execution?.runId ?? input.runId;
|
|
798
|
-
this.requireRun(runId);
|
|
801
|
+
const run = this.requireRun(runId);
|
|
799
802
|
const priorSequence = this.runSequences.get(runId) ?? 0;
|
|
800
803
|
if (input.sequence <= priorSequence)
|
|
801
804
|
return undefined;
|
|
@@ -818,7 +821,8 @@ export class NodeRuntimeState {
|
|
|
818
821
|
completedAt: terminal(input.status) ? value.createdAt : execution.completedAt
|
|
819
822
|
});
|
|
820
823
|
}
|
|
821
|
-
|
|
824
|
+
// Runner startup can arrive after a user stop; keep the Run cancelling.
|
|
825
|
+
if (input.status !== undefined && !terminal(input.status) && run.status !== 'CANCELLING')
|
|
822
826
|
this.transitionRun(runId, input.status);
|
|
823
827
|
this.projectEvent(value, execution?.turnId);
|
|
824
828
|
if (input.status !== undefined && terminal(input.status) && execution !== undefined)
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { resolveSessionRollbackTarget } from './session-rollback-target.js';
|
|
1
2
|
import { assertMcpServerNames } from '../mcp/naming.js';
|
|
3
|
+
import { imagePreviews, MAX_IMAGE_PREVIEW_SOURCE_BYTES } from './image-preview-service.js';
|
|
2
4
|
import { modelUsageGrantSchema, steerComposerMessage } from '@myagentroam/protocol';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { constants } from 'node:fs';
|
|
@@ -792,10 +794,10 @@ export class HostedConversationNodeService {
|
|
|
792
794
|
const turns = await this.readAllHistory(conversation, paths);
|
|
793
795
|
const targetIndex = turns.findIndex((turn) => turn.id === input.turnId);
|
|
794
796
|
const target = turns[targetIndex];
|
|
795
|
-
const
|
|
797
|
+
const selectedInput = target === undefined ? undefined : resolveSessionRollbackTarget(target, input.itemId);
|
|
796
798
|
if (target === undefined ||
|
|
797
799
|
!terminalHostedTurnStatus(target.status) ||
|
|
798
|
-
|
|
800
|
+
selectedInput === undefined ||
|
|
799
801
|
target.rewind?.before !== input.boundary)
|
|
800
802
|
throw new Error('HOSTED_CONVERSATION_ROLLBACK_TARGET_INVALID');
|
|
801
803
|
let rolledBack;
|
|
@@ -805,7 +807,15 @@ export class HostedConversationNodeService {
|
|
|
805
807
|
sessionId: conversation.sessionId,
|
|
806
808
|
homeDirectory: paths.homeDirectory,
|
|
807
809
|
modelId: conversation.modelId,
|
|
808
|
-
boundaryTurnId: input.boundary
|
|
810
|
+
boundaryTurnId: input.boundary,
|
|
811
|
+
...(selectedInput.clientMessageId === undefined
|
|
812
|
+
? {}
|
|
813
|
+
: {
|
|
814
|
+
beforeInput: {
|
|
815
|
+
turnId: target.rewind.after,
|
|
816
|
+
clientMessageId: selectedInput.clientMessageId
|
|
817
|
+
}
|
|
818
|
+
})
|
|
809
819
|
});
|
|
810
820
|
}
|
|
811
821
|
catch (error) {
|
|
@@ -1016,14 +1026,16 @@ export class HostedConversationNodeService {
|
|
|
1016
1026
|
const image = await this.options.readImage(input.runId, input.imageRef);
|
|
1017
1027
|
if (image === undefined)
|
|
1018
1028
|
throw new Error('RUN_IMAGE_NOT_FOUND');
|
|
1019
|
-
return {
|
|
1029
|
+
return {
|
|
1030
|
+
image: input.preview === undefined ? image : await imagePreviews.fromImage(image, input.preview)
|
|
1031
|
+
};
|
|
1020
1032
|
}
|
|
1021
1033
|
async readFile(raw) {
|
|
1022
1034
|
const payload = hostedRequest(raw);
|
|
1023
1035
|
const input = hostedConversationFileReadInputSchema.parse(payload.data);
|
|
1024
1036
|
const file = this.options.store.requireFile(payload.userId, input.conversationId, input.fileId);
|
|
1025
1037
|
const path = this.options.store.filePath(payload.userId, file);
|
|
1026
|
-
const offset = input.offset ?? 0;
|
|
1038
|
+
const offset = input.preview === undefined ? (input.offset ?? 0) : 0;
|
|
1027
1039
|
const limit = Math.min(input.limit ?? MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
|
|
1028
1040
|
const handle = await openRegularFile(path);
|
|
1029
1041
|
try {
|
|
@@ -1034,12 +1046,26 @@ export class HostedConversationNodeService {
|
|
|
1034
1046
|
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
1035
1047
|
if (offset > before.size)
|
|
1036
1048
|
throw new Error('HOSTED_CONVERSATION_FILE_RANGE_INVALID');
|
|
1037
|
-
|
|
1049
|
+
if (input.preview !== undefined && before.size > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
|
|
1050
|
+
throw new Error('IMAGE_PREVIEW_TOO_LARGE');
|
|
1051
|
+
const buffer = Buffer.alloc(input.preview === undefined
|
|
1052
|
+
? Math.min(limit, Math.max(0, before.size - offset))
|
|
1053
|
+
: before.size);
|
|
1038
1054
|
const read = await handle.read(buffer, 0, buffer.length, offset);
|
|
1055
|
+
if (input.preview !== undefined) {
|
|
1056
|
+
while (read.bytesRead < buffer.length) {
|
|
1057
|
+
const next = await handle.read(buffer, read.bytesRead, buffer.length - read.bytesRead, read.bytesRead);
|
|
1058
|
+
if (next.bytesRead === 0)
|
|
1059
|
+
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
1060
|
+
read.bytesRead += next.bytesRead;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1039
1063
|
const after = await handle.stat();
|
|
1040
1064
|
const currentPathMetadata = await regularFileMetadata(path);
|
|
1041
1065
|
if (!sameFileSnapshot(before, after) || !sameFileSnapshot(after, currentPathMetadata))
|
|
1042
1066
|
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
1067
|
+
if (input.preview !== undefined)
|
|
1068
|
+
return { image: await imagePreviews.create(buffer, input.preview) };
|
|
1043
1069
|
const next = offset + read.bytesRead;
|
|
1044
1070
|
const eof = next >= after.size;
|
|
1045
1071
|
const currentFile = {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
export const MAX_IMAGE_PREVIEW_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
const MAX_CACHE_ENTRIES = 64;
|
|
6
|
+
const MAX_PENDING = 16;
|
|
7
|
+
const MAX_PENDING_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
/** Sources must be read through their original authorization boundary before every call. */
|
|
9
|
+
export class ImagePreviewService {
|
|
10
|
+
cache = new Map();
|
|
11
|
+
pending = new Map();
|
|
12
|
+
cacheBytes = 0;
|
|
13
|
+
pendingBytes = 0;
|
|
14
|
+
tail = Promise.resolve();
|
|
15
|
+
async create(bytes, options) {
|
|
16
|
+
if (bytes.length === 0)
|
|
17
|
+
throw new Error('IMAGE_PREVIEW_INVALID');
|
|
18
|
+
if (bytes.length > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
|
|
19
|
+
throw new Error('IMAGE_PREVIEW_TOO_LARGE');
|
|
20
|
+
const key = createHash('sha256')
|
|
21
|
+
.update(`${options.maxWidth}x${options.maxHeight}\0`)
|
|
22
|
+
.update(bytes)
|
|
23
|
+
.digest('hex');
|
|
24
|
+
const cached = this.cache.get(key);
|
|
25
|
+
if (cached !== undefined) {
|
|
26
|
+
this.cache.delete(key);
|
|
27
|
+
this.cache.set(key, cached);
|
|
28
|
+
return {
|
|
29
|
+
mime: 'image/webp',
|
|
30
|
+
dataBase64: cached.bytes.toString('base64'),
|
|
31
|
+
resized: cached.resized
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const current = this.pending.get(key);
|
|
35
|
+
if (current !== undefined)
|
|
36
|
+
return current;
|
|
37
|
+
if (this.pending.size >= MAX_PENDING || this.pendingBytes + bytes.length > MAX_PENDING_BYTES)
|
|
38
|
+
throw new Error('IMAGE_PREVIEW_BUSY');
|
|
39
|
+
this.pendingBytes += bytes.length;
|
|
40
|
+
const result = this.tail
|
|
41
|
+
.catch(() => undefined)
|
|
42
|
+
.then(async () => {
|
|
43
|
+
let output;
|
|
44
|
+
let resized;
|
|
45
|
+
try {
|
|
46
|
+
const pipeline = sharp(bytes, { limitInputPixels: 64 * 1024 * 1024, pages: 1 });
|
|
47
|
+
const metadata = await pipeline.metadata();
|
|
48
|
+
const rotated = [5, 6, 7, 8].includes(metadata.orientation ?? 1);
|
|
49
|
+
const width = rotated ? metadata.height : metadata.width;
|
|
50
|
+
const height = rotated ? metadata.width : metadata.height;
|
|
51
|
+
if (width === undefined || height === undefined)
|
|
52
|
+
throw new Error('IMAGE_PREVIEW_INVALID');
|
|
53
|
+
resized = width > options.maxWidth || height > options.maxHeight;
|
|
54
|
+
output = await pipeline
|
|
55
|
+
.rotate()
|
|
56
|
+
.resize({
|
|
57
|
+
width: options.maxWidth,
|
|
58
|
+
height: options.maxHeight,
|
|
59
|
+
fit: 'inside',
|
|
60
|
+
withoutEnlargement: true
|
|
61
|
+
})
|
|
62
|
+
.webp({ quality: 85, effort: 3 })
|
|
63
|
+
.toBuffer();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('IMAGE_PREVIEW_INVALID');
|
|
67
|
+
}
|
|
68
|
+
this.cache.set(key, { bytes: output, resized });
|
|
69
|
+
this.cacheBytes += output.length;
|
|
70
|
+
while (this.cache.size > MAX_CACHE_ENTRIES || this.cacheBytes > MAX_CACHE_BYTES) {
|
|
71
|
+
const oldest = this.cache.keys().next().value;
|
|
72
|
+
if (oldest === undefined)
|
|
73
|
+
break;
|
|
74
|
+
this.cacheBytes -= this.cache.get(oldest).bytes.length;
|
|
75
|
+
this.cache.delete(oldest);
|
|
76
|
+
}
|
|
77
|
+
return { mime: 'image/webp', dataBase64: output.toString('base64'), resized };
|
|
78
|
+
});
|
|
79
|
+
this.pending.set(key, result);
|
|
80
|
+
this.tail = result;
|
|
81
|
+
try {
|
|
82
|
+
return await result;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
this.pending.delete(key);
|
|
86
|
+
this.pendingBytes -= bytes.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
fromImage(image, options) {
|
|
90
|
+
const source = image;
|
|
91
|
+
if (typeof source?.dataBase64 !== 'string')
|
|
92
|
+
throw new Error('IMAGE_PREVIEW_INVALID');
|
|
93
|
+
if (source.dataBase64.length > Math.ceil(MAX_IMAGE_PREVIEW_SOURCE_BYTES / 3) * 4)
|
|
94
|
+
throw new Error('IMAGE_PREVIEW_TOO_LARGE');
|
|
95
|
+
if (this.pendingBytes + Math.floor((source.dataBase64.length * 3) / 4) > MAX_PENDING_BYTES)
|
|
96
|
+
throw new Error('IMAGE_PREVIEW_BUSY');
|
|
97
|
+
return this.create(Buffer.from(source.dataBase64, 'base64'), options);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export const imagePreviews = new ImagePreviewService();
|
|
@@ -4,7 +4,7 @@ export class NativeSessionProjectionService {
|
|
|
4
4
|
constructor(options) {
|
|
5
5
|
this.options = options;
|
|
6
6
|
}
|
|
7
|
-
async discover(runnerName, workspaceId) {
|
|
7
|
+
async discover(runnerName, workspaceId, cleanup = false) {
|
|
8
8
|
const database = this.requireDatabase();
|
|
9
9
|
const workspace = workspaceId === undefined ? undefined : database.getWorkspace(workspaceId);
|
|
10
10
|
if (workspaceId !== undefined && workspace === undefined)
|
|
@@ -13,11 +13,13 @@ export class NativeSessionProjectionService {
|
|
|
13
13
|
if (runner === undefined || !runner.available(this.options.capabilities()))
|
|
14
14
|
throw new Error('RUNNER_UNAVAILABLE');
|
|
15
15
|
const sessions = await runner.discoverSessions({
|
|
16
|
+
...(cleanup ? { cleanup: true } : {}),
|
|
16
17
|
workspaces: database.listWorkspaces(),
|
|
17
18
|
createProjection: (input) => this.project(input)
|
|
18
19
|
}, workspace);
|
|
19
20
|
const ordered = deduplicateDirectSessions(sessions);
|
|
20
|
-
|
|
21
|
+
if (!cleanup)
|
|
22
|
+
this.remember(ordered);
|
|
21
23
|
return ordered;
|
|
22
24
|
}
|
|
23
25
|
async discoverCodex(workspaceId) {
|
|
@@ -33,13 +35,13 @@ export class NativeSessionProjectionService {
|
|
|
33
35
|
this.remember(sessions);
|
|
34
36
|
return sessions.map(this.options.present);
|
|
35
37
|
}
|
|
36
|
-
async discoverWorkspace(workspaceId, onRunnerDiscovered) {
|
|
38
|
+
async discoverWorkspace(workspaceId, onRunnerDiscovered, cleanup = false) {
|
|
37
39
|
const discovered = await Promise.allSettled(this.options.runners
|
|
38
40
|
.advertised()
|
|
39
41
|
.filter((runner) => runner.available(this.options.capabilities()))
|
|
40
42
|
.map(async (runner) => {
|
|
41
43
|
const name = runner.name;
|
|
42
|
-
const sessions = await this.discover(name, workspaceId);
|
|
44
|
+
const sessions = await this.discover(name, workspaceId, cleanup);
|
|
43
45
|
onRunnerDiscovered?.(name, sessions);
|
|
44
46
|
return sessions;
|
|
45
47
|
}));
|
|
@@ -47,7 +49,8 @@ export class NativeSessionProjectionService {
|
|
|
47
49
|
if (failed?.status === 'rejected')
|
|
48
50
|
throw failed.reason;
|
|
49
51
|
const sessions = deduplicateDirectSessions(discovered.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])));
|
|
50
|
-
|
|
52
|
+
if (!cleanup)
|
|
53
|
+
this.remember(sessions);
|
|
51
54
|
return sessions;
|
|
52
55
|
}
|
|
53
56
|
async resolve(sessionId, registeredOnly = false) {
|
|
@@ -41,6 +41,9 @@ export class RecentRunProjectionService {
|
|
|
41
41
|
...(latestUserMessage === undefined ? {} : { userMessage: latestUserMessage }),
|
|
42
42
|
runner: run.runner,
|
|
43
43
|
status: run.status,
|
|
44
|
+
...(run.interruptionReason === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { interruptionReason: run.interruptionReason }),
|
|
44
47
|
createdAt: run.createdAt,
|
|
45
48
|
startedAt,
|
|
46
49
|
completedAt,
|
|
@@ -4,6 +4,7 @@ import { isTerminalRunStatus } from '../util/node-operation-parsers.js';
|
|
|
4
4
|
import { isPlainRecord } from '../util/node-operation-parsers.js';
|
|
5
5
|
import { isHostedConversationImageRunId } from '../runner/mar-agent/generated-images.js';
|
|
6
6
|
import { assertRunOwner } from './run-ownership-policy.js';
|
|
7
|
+
import { imagePreviews } from './image-preview-service.js';
|
|
7
8
|
export class RunWorkbenchService {
|
|
8
9
|
options;
|
|
9
10
|
constructor(options) {
|
|
@@ -47,7 +48,11 @@ export class RunWorkbenchService {
|
|
|
47
48
|
const image = await this.options.readImage(reference.runId, selector);
|
|
48
49
|
if (image === undefined)
|
|
49
50
|
throw new Error('RUN_IMAGE_NOT_FOUND');
|
|
50
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
image: reference.preview === undefined
|
|
53
|
+
? image
|
|
54
|
+
: await imagePreviews.fromImage(image, reference.preview)
|
|
55
|
+
};
|
|
51
56
|
}
|
|
52
57
|
respond(input) {
|
|
53
58
|
if (typeof input.runId !== 'string' ||
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { resolveSessionRollbackTarget } from './session-rollback-target.js';
|
|
1
2
|
import { sessionGroup } from './session-presentation-service.js';
|
|
2
3
|
import { removeNativeSession } from '../native-session-history.js';
|
|
3
4
|
import { nodeLog } from '../operational.js';
|
|
4
5
|
import { isTerminalRunStatus, sessionPage } from '../util/node-operation-parsers.js';
|
|
5
6
|
import { latestSessionActivityAt } from './session-presentation-service.js';
|
|
6
7
|
import { marctlSessionSummary } from './session-recent-activity.js';
|
|
8
|
+
import { WorkspaceSessionCleanupBudget, WorkspaceSessionCleanupDeadline } from './workspace-session-cleanup-budget.js';
|
|
7
9
|
export class SessionCatalogService {
|
|
8
10
|
options;
|
|
9
11
|
rewindQueue = Promise.resolve();
|
|
@@ -15,7 +17,7 @@ export class SessionCatalogService {
|
|
|
15
17
|
'session.list': (data) => this.list(record(data)),
|
|
16
18
|
'session.graph': (data) => this.graph(record(data)),
|
|
17
19
|
'session.remove': (data) => this.enqueueMutation(() => this.remove(record(data))),
|
|
18
|
-
'workspace.sessions.cleanup': (data) => this.
|
|
20
|
+
'workspace.sessions.cleanup': (data) => this.cleanupWorkspaceSessions(record(data)),
|
|
19
21
|
'session.rewind': (data) => this.enqueueMutation(() => this.rewind(record(data))),
|
|
20
22
|
'session.rollback': (data) => this.enqueueMutation(() => this.rollback(record(data)))
|
|
21
23
|
};
|
|
@@ -195,6 +197,7 @@ export class SessionCatalogService {
|
|
|
195
197
|
async rollback(input) {
|
|
196
198
|
if (!validRewindId(input.sessionId, 128) ||
|
|
197
199
|
!validRewindId(input.turnId, 128) ||
|
|
200
|
+
(input.itemId !== undefined && !validRewindId(input.itemId, 128)) ||
|
|
198
201
|
(input.boundary !== null && !validRewindId(input.boundary, 256)))
|
|
199
202
|
throw new Error('SESSION_ROLLBACK_INVALID');
|
|
200
203
|
const source = this.options.runtime.getAgentSession(input.sessionId) ??
|
|
@@ -211,15 +214,17 @@ export class SessionCatalogService {
|
|
|
211
214
|
if (this.options.runtime.sessionQueue(source.id).itemIds.length > 0)
|
|
212
215
|
throw new Error('SESSION_QUEUE_NOT_EMPTY');
|
|
213
216
|
const turn = await this.findTurn(source, input.turnId);
|
|
214
|
-
const
|
|
217
|
+
const target = turn === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: resolveSessionRollbackTarget(turn, input.itemId);
|
|
215
220
|
if (turn === undefined ||
|
|
216
221
|
!terminalConversationStatus(turn.status) ||
|
|
217
|
-
|
|
222
|
+
target === undefined ||
|
|
218
223
|
turn.rewind?.before !== input.boundary)
|
|
219
224
|
throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
|
|
220
225
|
const releaseMutation = this.options.runtime.beginSessionHistoryMutation(source.id);
|
|
221
226
|
try {
|
|
222
|
-
const lastActivityAt = await runner.rollbackNative(source, input.boundary, turn.rewind?.after);
|
|
227
|
+
const lastActivityAt = await runner.rollbackNative(source, input.boundary, turn.rewind?.after, target.clientMessageId);
|
|
223
228
|
const runtimeSession = this.options.runtime.getAgentSession(source.id);
|
|
224
229
|
const session = runtimeSession === undefined
|
|
225
230
|
? { ...source, lastActivityAt }
|
|
@@ -313,22 +318,50 @@ export class SessionCatalogService {
|
|
|
313
318
|
throw new Error('WORKSPACE_SESSION_CLEANUP_INVALID');
|
|
314
319
|
if (this.options.database().getWorkspace(input.workspaceId) === undefined)
|
|
315
320
|
throw new Error('WORKSPACE_NOT_FOUND');
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
321
|
+
const workspaceId = input.workspaceId;
|
|
322
|
+
const budget = new WorkspaceSessionCleanupBudget();
|
|
323
|
+
const started = Promise.withResolvers();
|
|
324
|
+
const work = this.enqueueMutation(() => {
|
|
325
|
+
started.resolve();
|
|
326
|
+
return this.performWorkspaceSessionCleanup(workspaceId, inactiveDays, budget);
|
|
327
|
+
});
|
|
328
|
+
// A queued cleanup may expire before acquiring the mutation queue. Its late
|
|
329
|
+
// callback still checks the budget and cannot start discovery or deletion.
|
|
330
|
+
void work.catch(() => undefined);
|
|
331
|
+
try {
|
|
332
|
+
await budget.read(() => started.promise);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (!(error instanceof WorkspaceSessionCleanupDeadline))
|
|
336
|
+
throw error;
|
|
337
|
+
return { deleted: 0, skipped: 0, failed: 0, deletedByRunner: {}, timedOut: true };
|
|
324
338
|
}
|
|
325
|
-
|
|
339
|
+
return work;
|
|
340
|
+
}
|
|
341
|
+
async performWorkspaceSessionCleanup(workspaceId, inactiveDays, budget) {
|
|
342
|
+
const startedAt = performance.now();
|
|
343
|
+
const cutoff = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
|
|
326
344
|
let deleted = 0;
|
|
327
345
|
let skipped = 0;
|
|
328
346
|
let failed = 0;
|
|
329
347
|
const deletedByRunner = {};
|
|
348
|
+
let candidateCount;
|
|
349
|
+
let timedOut = false;
|
|
350
|
+
let discoveryMs = 0;
|
|
330
351
|
try {
|
|
352
|
+
const nativeSessions = await budget.read(() => this.options.discoverWorkspace(workspaceId, true));
|
|
353
|
+
discoveryMs = Math.round(performance.now() - startedAt);
|
|
354
|
+
const candidates = new Map();
|
|
355
|
+
for (const native of deduplicateNativeSessions(nativeSessions)) {
|
|
356
|
+
if (native.workspaceId !== workspaceId)
|
|
357
|
+
continue;
|
|
358
|
+
const session = this.cleanupCandidate(native);
|
|
359
|
+
candidates.set(session.id, session);
|
|
360
|
+
}
|
|
361
|
+
this.mergePersisted(candidates, workspaceId, false);
|
|
362
|
+
candidateCount = candidates.size;
|
|
331
363
|
for (const session of candidates.values()) {
|
|
364
|
+
budget.check();
|
|
332
365
|
const missingCandidate = session.externalDiscovered === false;
|
|
333
366
|
if (session.externalSessionId === null ||
|
|
334
367
|
(missingCandidate &&
|
|
@@ -350,7 +383,7 @@ export class SessionCatalogService {
|
|
|
350
383
|
continue;
|
|
351
384
|
}
|
|
352
385
|
try {
|
|
353
|
-
if ((await this.removeResolved(session, true)) === 'deleted') {
|
|
386
|
+
if ((await this.removeResolved(session, true, budget)) === 'deleted') {
|
|
354
387
|
deleted += 1;
|
|
355
388
|
deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
|
|
356
389
|
}
|
|
@@ -358,12 +391,14 @@ export class SessionCatalogService {
|
|
|
358
391
|
skipped += 1;
|
|
359
392
|
}
|
|
360
393
|
catch (error) {
|
|
394
|
+
if (error instanceof WorkspaceSessionCleanupDeadline)
|
|
395
|
+
throw error;
|
|
361
396
|
if (isCleanupSkip(error))
|
|
362
397
|
skipped += 1;
|
|
363
398
|
else {
|
|
364
399
|
failed += 1;
|
|
365
400
|
nodeLog('workspace.session.cleanup.failed', {
|
|
366
|
-
workspaceId
|
|
401
|
+
workspaceId,
|
|
367
402
|
sessionId: session.id,
|
|
368
403
|
runner: session.runner,
|
|
369
404
|
...cleanupFailureDiagnostic(error)
|
|
@@ -371,11 +406,38 @@ export class SessionCatalogService {
|
|
|
371
406
|
}
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
|
-
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (!(error instanceof WorkspaceSessionCleanupDeadline))
|
|
412
|
+
throw error;
|
|
413
|
+
if (candidateCount === undefined)
|
|
414
|
+
discoveryMs = Math.round(performance.now() - startedAt);
|
|
415
|
+
timedOut = true;
|
|
375
416
|
}
|
|
376
417
|
finally {
|
|
377
|
-
this.options.invalidateWorkspaceSessions(
|
|
418
|
+
this.options.invalidateWorkspaceSessions(workspaceId);
|
|
378
419
|
}
|
|
420
|
+
const result = {
|
|
421
|
+
deleted,
|
|
422
|
+
skipped,
|
|
423
|
+
failed,
|
|
424
|
+
deletedByRunner,
|
|
425
|
+
...(timedOut
|
|
426
|
+
? {
|
|
427
|
+
timedOut: true,
|
|
428
|
+
...(candidateCount === undefined
|
|
429
|
+
? {}
|
|
430
|
+
: { remaining: candidateCount - deleted - skipped - failed })
|
|
431
|
+
}
|
|
432
|
+
: {})
|
|
433
|
+
};
|
|
434
|
+
nodeLog('workspace.sessions.cleanup.completed', {
|
|
435
|
+
workspaceId,
|
|
436
|
+
...result,
|
|
437
|
+
discoveryMs,
|
|
438
|
+
durationMs: Math.round(performance.now() - startedAt)
|
|
439
|
+
});
|
|
440
|
+
return result;
|
|
379
441
|
}
|
|
380
442
|
cleanupCandidate(native) {
|
|
381
443
|
const projected = this.options.projection.projectionForNative(native);
|
|
@@ -389,23 +451,21 @@ export class SessionCatalogService {
|
|
|
389
451
|
lastActivityAt: latestSessionActivityAt(projected.lastActivityAt, native.lastActivityAt)
|
|
390
452
|
};
|
|
391
453
|
}
|
|
392
|
-
async removeResolved(session, requireCleanupSafety = false) {
|
|
454
|
+
async removeResolved(session, requireCleanupSafety = false, budget) {
|
|
393
455
|
if (session.externalSessionId === null)
|
|
394
456
|
throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
|
|
395
457
|
const releaseMutation = this.options.runtime.beginSessionHistoryMutation(session.id, requireCleanupSafety ? undefined : { allowQueued: true });
|
|
396
458
|
try {
|
|
397
459
|
if (this.options.isRecovering?.(session.id))
|
|
398
460
|
throw new Error('SESSION_RESTART_PENDING');
|
|
399
|
-
const runner = this.options.runners.require(session.runner);
|
|
400
461
|
const missingCandidate = requireCleanupSafety && session.externalDiscovered === false;
|
|
401
|
-
if (missingCandidate && !(await runner.isNativeSessionMissing(session)))
|
|
402
|
-
return 'skipped';
|
|
403
462
|
if (this.options.runtime
|
|
404
463
|
.listRunsForSession(session.id)
|
|
405
464
|
.some((run) => !isTerminalRunStatus(run.status)))
|
|
406
465
|
throw new Error('SESSION_ACTIVE');
|
|
407
|
-
if (session.nativeControl === 'EXTERNAL') {
|
|
408
|
-
|
|
466
|
+
if (session.nativeControl === 'EXTERNAL' && !missingCandidate) {
|
|
467
|
+
const refresh = () => this.options.refreshActivity(session);
|
|
468
|
+
await (budget ? budget.read(refresh) : refresh());
|
|
409
469
|
if (this.options.externalActivity(session.id) === 'BUSY')
|
|
410
470
|
throw new Error('SESSION_ACTIVE');
|
|
411
471
|
}
|
|
@@ -414,17 +474,25 @@ export class SessionCatalogService {
|
|
|
414
474
|
throw new Error('SESSION_WATCHING');
|
|
415
475
|
if (this.options.runtime.sessionQueue(session.id).itemIds.length > 0)
|
|
416
476
|
throw new Error('SESSION_QUEUE_NOT_EMPTY');
|
|
477
|
+
// Pin updates do not share the cleanup mutation queue.
|
|
478
|
+
const pinOrder = this.options
|
|
479
|
+
.database()
|
|
480
|
+
.getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
|
|
481
|
+
if (pinOrder !== undefined && pinOrder !== null)
|
|
482
|
+
return 'skipped';
|
|
417
483
|
if (this.options.runtime
|
|
418
484
|
.listRunsForSession(session.id)
|
|
419
485
|
.some((run) => !isTerminalRunStatus(run.status)))
|
|
420
486
|
throw new Error('SESSION_ACTIVE');
|
|
421
487
|
}
|
|
488
|
+
budget?.check();
|
|
422
489
|
const nativeIds = this.options.nativeIds(session);
|
|
423
490
|
if (missingCandidate) {
|
|
424
491
|
this.options.database().deleteSessionRecord(session.id);
|
|
425
492
|
this.options.forget(session, nativeIds);
|
|
426
493
|
return 'deleted';
|
|
427
494
|
}
|
|
495
|
+
const runner = this.options.runners.require(session.runner);
|
|
428
496
|
let deleted = false;
|
|
429
497
|
for (const externalSessionId of nativeIds) {
|
|
430
498
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A missing message selector is unambiguous only when the Turn has one user input. */
|
|
2
|
+
export function resolveSessionRollbackTarget(turn, itemId) {
|
|
3
|
+
const inputs = turn.items.filter((item) => item.kind === 'user_message');
|
|
4
|
+
const selected = itemId === undefined
|
|
5
|
+
? inputs.length === 1
|
|
6
|
+
? inputs[0]
|
|
7
|
+
: undefined
|
|
8
|
+
: inputs.find((item) => item.id === itemId);
|
|
9
|
+
if (selected?.status !== 'COMPLETED')
|
|
10
|
+
return undefined;
|
|
11
|
+
if (selected === inputs[0])
|
|
12
|
+
return {};
|
|
13
|
+
const payload = selected.payload;
|
|
14
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
|
|
15
|
+
return undefined;
|
|
16
|
+
const clientMessageId = payload['clientMessageId'];
|
|
17
|
+
return typeof clientMessageId === 'string' &&
|
|
18
|
+
clientMessageId.trim() &&
|
|
19
|
+
clientMessageId.length <= 512
|
|
20
|
+
? { clientMessageId }
|
|
21
|
+
: undefined;
|
|
22
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
2
2
|
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
-
import { isComposerAttachmentResourcePath } from '@myagentroam/protocol';
|
|
3
|
+
import { imagePreviewOptionsSchema, isComposerAttachmentResourcePath } from '@myagentroam/protocol';
|
|
4
4
|
import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
|
|
5
5
|
import { revealWorkspaceFileInFileManager } from './workspace-file-manager-service.js';
|
|
6
|
+
import { imagePreviews, MAX_IMAGE_PREVIEW_SOURCE_BYTES } from './image-preview-service.js';
|
|
6
7
|
const INDEX_CACHE_LIMIT = 32;
|
|
7
8
|
const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
|
|
8
9
|
const INDEX_QUEUE_LIMIT = 16;
|
|
@@ -96,6 +97,27 @@ export class WorkspaceFileService {
|
|
|
96
97
|
? readAbsoluteFileContentRange(path, offset, limit)
|
|
97
98
|
: readWorkspaceFileContentRange(workspacePath, path, offset, limit);
|
|
98
99
|
}
|
|
100
|
+
async previewImage(workspace, input, workspacePath = workspace.path) {
|
|
101
|
+
const preview = imagePreviewOptionsSchema.parse(input.preview);
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let offset = 0;
|
|
104
|
+
let revision;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const chunk = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
|
|
107
|
+
if (chunk.size > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
|
|
108
|
+
throw new Error('IMAGE_PREVIEW_TOO_LARGE');
|
|
109
|
+
if (revision !== undefined && revision !== chunk.sourceRevision)
|
|
110
|
+
throw new Error('FILE_CHANGED');
|
|
111
|
+
revision = chunk.sourceRevision;
|
|
112
|
+
chunks.push(Buffer.from(chunk.contentBase64, 'base64'));
|
|
113
|
+
if (chunk.nextOffset === null)
|
|
114
|
+
break;
|
|
115
|
+
if (chunk.nextOffset <= offset)
|
|
116
|
+
throw new Error('FILE_RANGE_INVALID');
|
|
117
|
+
offset = chunk.nextOffset;
|
|
118
|
+
}
|
|
119
|
+
return imagePreviews.create(Buffer.concat(chunks), preview);
|
|
120
|
+
}
|
|
99
121
|
async resolveAttachmentResource(workspaceId, path) {
|
|
100
122
|
if (!isComposerAttachmentResourcePath(path))
|
|
101
123
|
throw new Error('FILE_PATH_ESCAPE');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const CLEANUP_EXECUTION_BUDGET_MS = 25_000;
|
|
2
|
+
export class WorkspaceSessionCleanupDeadline extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super('Workspace session cleanup reached its execution budget.');
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/** Read-only work can expire; an admitted native write must settle before returning counts. */
|
|
8
|
+
export class WorkspaceSessionCleanupBudget {
|
|
9
|
+
deadline = performance.now() + CLEANUP_EXECUTION_BUDGET_MS;
|
|
10
|
+
get expired() {
|
|
11
|
+
return performance.now() >= this.deadline;
|
|
12
|
+
}
|
|
13
|
+
check() {
|
|
14
|
+
if (this.expired)
|
|
15
|
+
throw new WorkspaceSessionCleanupDeadline();
|
|
16
|
+
}
|
|
17
|
+
async read(operation) {
|
|
18
|
+
this.check();
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
operation(),
|
|
23
|
+
new Promise((_resolve, reject) => {
|
|
24
|
+
timer = setTimeout(() => reject(new WorkspaceSessionCleanupDeadline()), Math.max(0, this.deadline - performance.now()));
|
|
25
|
+
})
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -164,6 +164,9 @@ export class WorkspaceWorkbenchService {
|
|
|
164
164
|
throw new Error('WORKSPACE_NOT_FOUND');
|
|
165
165
|
const workspace = this.options.requireWorkspace(input.workspaceId);
|
|
166
166
|
const repository = await this.fileRepository(workspace, input);
|
|
167
|
+
if (input.preview !== undefined) {
|
|
168
|
+
return { image: await this.options.files.previewImage(workspace, input, repository) };
|
|
169
|
+
}
|
|
167
170
|
return {
|
|
168
171
|
file: await this.options.files.readContent(workspace, input, repository),
|
|
169
172
|
...(input.previewDirectory === undefined ? {} : { previewBoundaryVerified: true })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.26",
|
|
4
4
|
"description": "MyAgentRoam Node runtime CLI.",
|
|
5
5
|
"license": "Apache-2.0 AND GPL-2.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"node-pty": "1.1.0",
|
|
36
36
|
"npm": "11.12.1",
|
|
37
37
|
"playwright": "1.62.1",
|
|
38
|
+
"sharp": "0.35.5",
|
|
38
39
|
"ws": "^8.21.3",
|
|
39
40
|
"zod": "4.4.3",
|
|
40
|
-
"@myagentroam/agent": "0.11.
|
|
41
|
-
"@myagentroam/
|
|
41
|
+
"@myagentroam/agent": "0.11.26",
|
|
42
|
+
"@myagentroam/code-navigation": "0.11.0",
|
|
42
43
|
"@myagentroam/marctl": "0.11.1",
|
|
43
|
-
"@myagentroam/
|
|
44
|
+
"@myagentroam/protocol": "0.11.26"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/ws": "^8.18.1"
|