@akira-tl/forgerelay 0.5.2 → 0.5.5
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/CHANGELOG.md +31 -0
- package/capabilities/batch-execution/GUIDE.md +18 -0
- package/dist/activity/audit-store.js +33 -1
- package/dist/activity/host-turn-store.js +46 -0
- package/dist/activity/lifecycle.js +7 -1
- package/dist/activity/mcp-query-tools.js +129 -0
- package/dist/activity/query-service.js +289 -0
- package/dist/capabilities.js +9 -0
- package/dist/capability-registry.js +28 -0
- package/dist/db/migrations.js +33 -0
- package/dist/db/schema.js +8 -0
- package/dist/file-mutations.js +33 -2
- package/dist/lsp/test-support/server-fixture.js +9 -2
- package/dist/mcp/server-instructions.js +3 -3
- package/dist/operations/batch/executor.js +216 -0
- package/dist/operations/batch/scheduler.js +108 -0
- package/dist/operations/batch/types.js +76 -0
- package/dist/operations/bulk-mutation.js +41 -0
- package/dist/operations/bulk-read.js +28 -0
- package/dist/operations/core-operation-executor.js +30 -0
- package/dist/operations/native-bulk-mutations.js +134 -0
- package/dist/pi-tools.js +36 -0
- package/dist/server.js +843 -495
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "./lsp/code-intelligence-types.js";
|
|
3
|
+
import { batchExecuteInputSchema, } from "./operations/batch/types.js";
|
|
3
4
|
export class CapabilityError extends Error {
|
|
4
5
|
code;
|
|
5
6
|
constructor(code, message) {
|
|
@@ -25,6 +26,7 @@ export class CapabilityRegistry {
|
|
|
25
26
|
name: definition.name,
|
|
26
27
|
description: definition.description,
|
|
27
28
|
available,
|
|
29
|
+
batchPolicy: definition.batchPolicy,
|
|
28
30
|
...(!available && unavailableReason ? { unavailableReason } : {}),
|
|
29
31
|
guide: {
|
|
30
32
|
name: definition.guideName,
|
|
@@ -65,6 +67,9 @@ export class CapabilityRegistry {
|
|
|
65
67
|
if (!catalogEntry.available) {
|
|
66
68
|
throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
|
|
67
69
|
}
|
|
70
|
+
if (options.batch && definition.batchPolicy === "unsupported") {
|
|
71
|
+
throw new CapabilityError("capability_batch_unsupported", `Capability ${name} is not supported inside batch.execute.`);
|
|
72
|
+
}
|
|
68
73
|
if (options.nativeFile !== undefined && !definition.nativeFileArgument) {
|
|
69
74
|
throw new CapabilityError("invalid_arguments", `Capability ${name} does not accept a Host-native file value.`);
|
|
70
75
|
}
|
|
@@ -87,6 +92,9 @@ export class CapabilityRegistry {
|
|
|
87
92
|
throw new CapabilityError("execution_failed", `Capability ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
batchPolicy(name) {
|
|
96
|
+
return this.definitions.get(name)?.batchPolicy;
|
|
97
|
+
}
|
|
90
98
|
requireDefinition(name) {
|
|
91
99
|
const definition = this.definitions.get(name);
|
|
92
100
|
if (!definition) {
|
|
@@ -105,6 +113,7 @@ export class CapabilityRegistry {
|
|
|
105
113
|
name: definition.name,
|
|
106
114
|
description: definition.description,
|
|
107
115
|
available,
|
|
116
|
+
batchPolicy: definition.batchPolicy,
|
|
108
117
|
...(!available && unavailableReason ? { unavailableReason } : {}),
|
|
109
118
|
guide: {
|
|
110
119
|
name: definition.guideName,
|
|
@@ -152,6 +161,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
152
161
|
description: "Validate the active ForgeRelay Hook configuration for this workspace.",
|
|
153
162
|
guideName: "lifecycle-hooks",
|
|
154
163
|
readGuideBeforeFirstUse: true,
|
|
164
|
+
batchPolicy: "parallel",
|
|
155
165
|
inputSchema: hooksCheckInput,
|
|
156
166
|
availability: () => ({ available: true }),
|
|
157
167
|
run: async (_input, context) => ({
|
|
@@ -167,6 +177,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
167
177
|
description: "Review accumulated workspace changes from the Git-backed review checkpoint.",
|
|
168
178
|
guideName: "artifacts-review",
|
|
169
179
|
readGuideBeforeFirstUse: true,
|
|
180
|
+
batchPolicy: "serial",
|
|
170
181
|
inputSchema: z.object({}).strict(),
|
|
171
182
|
availability: () => ({
|
|
172
183
|
available: dependencies.reviewChanges?.available ?? false,
|
|
@@ -181,6 +192,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
181
192
|
description: "Read semantic code information through an available Language server without changing the Workspace.",
|
|
182
193
|
guideName: "code-intelligence",
|
|
183
194
|
readGuideBeforeFirstUse: true,
|
|
195
|
+
batchPolicy: "parallel",
|
|
184
196
|
inputSchema: codeIntelligenceInput,
|
|
185
197
|
availability: () => ({
|
|
186
198
|
available: dependencies.codeIntelligence?.available ?? false,
|
|
@@ -189,12 +201,28 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
189
201
|
run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
|
|
190
202
|
}]
|
|
191
203
|
: []),
|
|
204
|
+
...(dependencies.batchExecute
|
|
205
|
+
? [{
|
|
206
|
+
name: "batch.execute",
|
|
207
|
+
description: "Execute multiple independent ForgeRelay core operations in one Agent interaction.",
|
|
208
|
+
guideName: "batch-execution",
|
|
209
|
+
readGuideBeforeFirstUse: true,
|
|
210
|
+
batchPolicy: "unsupported",
|
|
211
|
+
inputSchema: batchExecuteInputSchema,
|
|
212
|
+
availability: () => ({
|
|
213
|
+
available: dependencies.batchExecute?.available ?? false,
|
|
214
|
+
reason: dependencies.batchExecute?.unavailableReason,
|
|
215
|
+
}),
|
|
216
|
+
run: async (input, context, options) => dependencies.batchExecute.run(input, context, options),
|
|
217
|
+
}]
|
|
218
|
+
: []),
|
|
192
219
|
...(dependencies.downloadArtifact
|
|
193
220
|
? [{
|
|
194
221
|
name: "artifact.download",
|
|
195
222
|
description: "Save one Host-native file into a workspace-relative destination without overwriting.",
|
|
196
223
|
guideName: "artifacts-review",
|
|
197
224
|
readGuideBeforeFirstUse: true,
|
|
225
|
+
batchPolicy: "unsupported",
|
|
198
226
|
inputSchema: z.object({
|
|
199
227
|
file: z.strictObject({
|
|
200
228
|
download_url: z.string(),
|
package/dist/db/migrations.js
CHANGED
|
@@ -44,6 +44,16 @@ const migrations = [
|
|
|
44
44
|
name: "bash-output-audit",
|
|
45
45
|
up: migrateBashOutputAudit,
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
version: 10,
|
|
49
|
+
name: "activity-host-turns",
|
|
50
|
+
up: migrateActivityHostTurns,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
version: 11,
|
|
54
|
+
name: "activity-parent-child",
|
|
55
|
+
up: migrateActivityParentChild,
|
|
56
|
+
},
|
|
47
57
|
];
|
|
48
58
|
export function migrateDatabase(sqlite) {
|
|
49
59
|
const migrate = sqlite.transaction(() => {
|
|
@@ -296,6 +306,29 @@ function migrateBashOutputAudit(sqlite) {
|
|
|
296
306
|
on bash_output_chunks(output_id, sequence);
|
|
297
307
|
`);
|
|
298
308
|
}
|
|
309
|
+
function migrateActivityHostTurns(sqlite) {
|
|
310
|
+
sqlite.exec(`
|
|
311
|
+
create table if not exists activity_host_turns (
|
|
312
|
+
turn_id text primary key,
|
|
313
|
+
conversation_scope_id text,
|
|
314
|
+
created_at text not null
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
create index if not exists activity_host_turns_conversation_idx
|
|
318
|
+
on activity_host_turns(conversation_scope_id, created_at desc);
|
|
319
|
+
|
|
320
|
+
create index if not exists activity_host_turns_created_idx
|
|
321
|
+
on activity_host_turns(created_at desc);
|
|
322
|
+
`);
|
|
323
|
+
}
|
|
324
|
+
function migrateActivityParentChild(sqlite) {
|
|
325
|
+
sqlite.exec(`
|
|
326
|
+
alter table activity_audit_events add column parent_activity_id text;
|
|
327
|
+
|
|
328
|
+
create index if not exists activity_audit_events_parent_idx
|
|
329
|
+
on activity_audit_events(parent_activity_id, created_at);
|
|
330
|
+
`);
|
|
331
|
+
}
|
|
299
332
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
300
333
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
301
334
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -97,6 +97,14 @@ export const activityAuditEvents = sqliteTable("activity_audit_events", {
|
|
|
97
97
|
index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
|
|
98
98
|
index("activity_audit_events_created_idx").on(table.createdAt),
|
|
99
99
|
]);
|
|
100
|
+
export const activityHostTurns = sqliteTable("activity_host_turns", {
|
|
101
|
+
turnId: text("turn_id").primaryKey(),
|
|
102
|
+
conversationScopeId: text("conversation_scope_id"),
|
|
103
|
+
createdAt: text("created_at").notNull(),
|
|
104
|
+
}, (table) => [
|
|
105
|
+
index("activity_host_turns_conversation_idx").on(table.conversationScopeId, table.createdAt),
|
|
106
|
+
index("activity_host_turns_created_idx").on(table.createdAt),
|
|
107
|
+
]);
|
|
100
108
|
export const bashOutputStreams = sqliteTable("bash_output_streams", {
|
|
101
109
|
id: text("id").primaryKey(),
|
|
102
110
|
activityId: text("activity_id").notNull(),
|
package/dist/file-mutations.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { lstat, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
|
|
1
|
+
import { lstat, readdir, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { expandHomePath, resolveCanonicalAllowedPath } from "./roots.js";
|
|
3
|
+
import { expandHomePath, isPathInsideRoot, resolveCanonicalAllowedPath } from "./roots.js";
|
|
4
4
|
export async function renamePath(input, context) {
|
|
5
5
|
const source = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
|
|
6
6
|
await assertNotAllowedRootItself(source, context.allowedRoots, input.path);
|
|
@@ -10,6 +10,37 @@ export async function renamePath(input, context) {
|
|
|
10
10
|
await rename(source, destination);
|
|
11
11
|
return { path: input.path, newPath: input.newPath };
|
|
12
12
|
}
|
|
13
|
+
export async function preflightDeletePaths(inputs, context) {
|
|
14
|
+
const targets = await Promise.all(inputs.map(async (input) => {
|
|
15
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
|
|
16
|
+
await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
|
|
17
|
+
const entry = await lstat(path);
|
|
18
|
+
const recursive = input.recursive ?? false;
|
|
19
|
+
if (entry.isDirectory() && !recursive) {
|
|
20
|
+
const entries = await readdir(path);
|
|
21
|
+
if (entries.length > 0) {
|
|
22
|
+
throw new Error(`Directory is non-empty: ${input.path}. Use recursive=true to delete it.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
inputPath: input.path,
|
|
27
|
+
canonicalPath: await realpath(path),
|
|
28
|
+
};
|
|
29
|
+
}));
|
|
30
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
31
|
+
const current = targets[index];
|
|
32
|
+
for (let otherIndex = index + 1; otherIndex < targets.length; otherIndex += 1) {
|
|
33
|
+
const other = targets[otherIndex];
|
|
34
|
+
if (current.canonicalPath === other.canonicalPath) {
|
|
35
|
+
throw new Error(`Bulk delete targets overlap: ${current.inputPath} and ${other.inputPath} resolve to the same path.`);
|
|
36
|
+
}
|
|
37
|
+
if (isPathInsideRoot(other.canonicalPath, current.canonicalPath) ||
|
|
38
|
+
isPathInsideRoot(current.canonicalPath, other.canonicalPath)) {
|
|
39
|
+
throw new Error(`Bulk delete targets overlap as ancestor/descendant paths: ${current.inputPath} and ${other.inputPath}.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
13
44
|
export async function deletePath(input, context) {
|
|
14
45
|
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
|
|
15
46
|
await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
|
|
@@ -6,7 +6,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
6
6
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
7
|
import { ActivityAuditStore } from "../../activity/audit-store.js";
|
|
8
8
|
import { BashOutputStore } from "../../activity/bash-output-store.js";
|
|
9
|
+
import { HostTurnStore } from "../../activity/host-turn-store.js";
|
|
9
10
|
import { ActivityLifecycle } from "../../activity/lifecycle.js";
|
|
11
|
+
import { ActivityQueryService } from "../../activity/query-service.js";
|
|
10
12
|
import { loadConfig } from "../../config.js";
|
|
11
13
|
import { createReviewCheckpointManager } from "../../review-checkpoints.js";
|
|
12
14
|
import { ProcessManager } from "../../process-sessions.js";
|
|
@@ -37,10 +39,14 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
37
39
|
const workspaces = new WorkspaceRegistry(config, store);
|
|
38
40
|
const auditStore = new ActivityAuditStore(stateDir);
|
|
39
41
|
const bashOutputStore = new BashOutputStore(stateDir);
|
|
42
|
+
const hostTurnStore = new HostTurnStore(stateDir);
|
|
43
|
+
const activityQueries = new ActivityQueryService(hostTurnStore, auditStore, bashOutputStore);
|
|
40
44
|
const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
|
|
41
|
-
const activityLifecycle = new ActivityLifecycle(auditStore
|
|
45
|
+
const activityLifecycle = new ActivityLifecycle(auditStore, {
|
|
46
|
+
turnIdForConversation: (conversationScopeId) => activityQueries.currentTurnId(conversationScopeId),
|
|
47
|
+
});
|
|
42
48
|
const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
|
|
43
|
-
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
|
|
49
|
+
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
|
|
44
50
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
45
51
|
const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
|
|
46
52
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
@@ -53,6 +59,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
53
59
|
await server.close();
|
|
54
60
|
await codeIntelligence.shutdown();
|
|
55
61
|
processSessions.shutdown();
|
|
62
|
+
hostTurnStore.close();
|
|
56
63
|
bashOutputStore.close();
|
|
57
64
|
auditStore.close();
|
|
58
65
|
store.close();
|
|
@@ -24,11 +24,11 @@ export function buildToolDescriptions(config) {
|
|
|
24
24
|
? ""
|
|
25
25
|
: " Use shell commands for search and directory inspection instead of dedicated MCP search tools.";
|
|
26
26
|
return {
|
|
27
|
-
read: `Read
|
|
27
|
+
read: `Read one file or multiple files inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; offset/limit apply to every target in a bulk read. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
28
28
|
write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
29
|
-
edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
29
|
+
edit: `Edit one file or multiple files inside an open workspace or the OS temp directory by replacing exact text blocks. Use path for one target or paths for multiple targets; a bulk Edit applies the same edits to every file and preflights all targets before the first mutation. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
30
30
|
rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
31
|
-
delete: `Delete one
|
|
31
|
+
delete: `Delete one path or multiple paths inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; a bulk Delete preflights all targets before deleting anything. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
32
32
|
applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
33
33
|
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace containment does not make shell execution a sandbox. For action=run, yieldTimeMs is only the feedback wait (default 10000ms; 0 returns a processId immediately) and optional timeoutMs is the independent total execution limit. action=process polls/waits for incremental output, writes input, resizes a PTY, or interrupts by processId. Keep explicit waits below the Host request deadline; use 60000ms only when supported. Completed background results may be attached to a later result for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
|
|
34
34
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { openAiConversationScopeId } from "../../request-meta.js";
|
|
3
|
+
import { BatchScheduler } from "./scheduler.js";
|
|
4
|
+
export class BatchExecutor {
|
|
5
|
+
dependencies;
|
|
6
|
+
scheduler;
|
|
7
|
+
shellSurface;
|
|
8
|
+
constructor(dependencies) {
|
|
9
|
+
this.dependencies = dependencies;
|
|
10
|
+
this.scheduler = dependencies.scheduler ?? new BatchScheduler();
|
|
11
|
+
this.shellSurface = dependencies.shellSurface ?? "bash";
|
|
12
|
+
}
|
|
13
|
+
async run(workspaceId, input, context) {
|
|
14
|
+
const workspace = this.dependencies.workspaces.getWorkspace(workspaceId);
|
|
15
|
+
let response;
|
|
16
|
+
await this.dependencies.lifecycle.run({
|
|
17
|
+
tool: "batch",
|
|
18
|
+
workspace: workspaceSnapshot(workspace),
|
|
19
|
+
conversationScopeId: openAiConversationScopeId(context.requestMeta),
|
|
20
|
+
request: {
|
|
21
|
+
workspaceId,
|
|
22
|
+
concurrency: input.concurrency ?? Math.min(input.tasks.length, 10),
|
|
23
|
+
tasks: input.tasks.map((task) => ({ id: task.id, operation: task.operation })),
|
|
24
|
+
},
|
|
25
|
+
operation: async (parentContext) => {
|
|
26
|
+
const scheduled = input.tasks.map((task) => this.scheduledTask(workspace, task, context, parentContext));
|
|
27
|
+
const results = await this.scheduler.run(scheduled, {
|
|
28
|
+
concurrency: input.concurrency,
|
|
29
|
+
signal: context.signal,
|
|
30
|
+
});
|
|
31
|
+
const children = results.map((result, index) => {
|
|
32
|
+
const task = input.tasks[index];
|
|
33
|
+
if (result.status === "error") {
|
|
34
|
+
return {
|
|
35
|
+
id: task.id,
|
|
36
|
+
operation: task.operation,
|
|
37
|
+
status: "error",
|
|
38
|
+
error: result.error,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const childResult = sanitizeChildResult(result.value.response);
|
|
42
|
+
return {
|
|
43
|
+
id: task.id,
|
|
44
|
+
operation: task.operation,
|
|
45
|
+
status: result.value.failed ? "error" : "done",
|
|
46
|
+
result: childResult,
|
|
47
|
+
...(result.value.failed
|
|
48
|
+
? { error: childFailureMessage(result.value.response) }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
const failed = children.filter((child) => child.status === "error").length;
|
|
53
|
+
response = {
|
|
54
|
+
status: failed > 0 ? "partial" : "done",
|
|
55
|
+
tasks: children.length,
|
|
56
|
+
completed: children.length - failed,
|
|
57
|
+
failed,
|
|
58
|
+
results: children,
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
childCount: children.length,
|
|
62
|
+
completed: children.length - failed,
|
|
63
|
+
failed,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
outcome: batchParentOutcome,
|
|
67
|
+
});
|
|
68
|
+
if (!response)
|
|
69
|
+
throw new Error("Batch execution completed without a response.");
|
|
70
|
+
return response;
|
|
71
|
+
}
|
|
72
|
+
scheduledTask(workspace, task, context, parent) {
|
|
73
|
+
return {
|
|
74
|
+
id: task.id,
|
|
75
|
+
claims: taskClaims(workspace.root, task),
|
|
76
|
+
...(batchTaskIsExclusive(task, this.dependencies.capabilityBatchPolicy) ? { exclusive: true } : {}),
|
|
77
|
+
run: async (signal) => {
|
|
78
|
+
const response = await this.runCoreTask(workspace.id, task, {
|
|
79
|
+
...context,
|
|
80
|
+
signal,
|
|
81
|
+
parentActivityId: parent.activityId,
|
|
82
|
+
turnId: parent.turnId,
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
response,
|
|
86
|
+
failed: this.dependencies.resultIsError(response),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
runCoreTask(workspaceId, task, context) {
|
|
92
|
+
switch (task.operation) {
|
|
93
|
+
case "read":
|
|
94
|
+
return this.dependencies.coreOperations.read({
|
|
95
|
+
workspaceId,
|
|
96
|
+
path: task.path,
|
|
97
|
+
offset: task.offset,
|
|
98
|
+
limit: task.limit,
|
|
99
|
+
}, context);
|
|
100
|
+
case "write":
|
|
101
|
+
return this.dependencies.coreOperations.write({
|
|
102
|
+
workspaceId,
|
|
103
|
+
path: task.path,
|
|
104
|
+
content: task.content,
|
|
105
|
+
}, context);
|
|
106
|
+
case "edit":
|
|
107
|
+
return this.dependencies.coreOperations.edit({
|
|
108
|
+
workspaceId,
|
|
109
|
+
path: task.path,
|
|
110
|
+
edits: task.edits,
|
|
111
|
+
}, context);
|
|
112
|
+
case "rename":
|
|
113
|
+
return this.dependencies.coreOperations.rename({
|
|
114
|
+
workspaceId,
|
|
115
|
+
path: task.path,
|
|
116
|
+
newPath: task.newPath,
|
|
117
|
+
}, context);
|
|
118
|
+
case "delete":
|
|
119
|
+
return this.dependencies.coreOperations.delete({
|
|
120
|
+
workspaceId,
|
|
121
|
+
path: task.path,
|
|
122
|
+
recursive: task.recursive,
|
|
123
|
+
}, context);
|
|
124
|
+
case "capability.run":
|
|
125
|
+
return this.dependencies.coreOperations.capabilityRun({
|
|
126
|
+
workspaceId,
|
|
127
|
+
name: task.name,
|
|
128
|
+
arguments: task.arguments,
|
|
129
|
+
}, { ...context, batch: true });
|
|
130
|
+
case "bash.run":
|
|
131
|
+
return this.dependencies.coreOperations.shellRun({
|
|
132
|
+
workspaceId,
|
|
133
|
+
command: task.command,
|
|
134
|
+
surface: this.shellSurface,
|
|
135
|
+
tty: task.tty,
|
|
136
|
+
columns: task.columns,
|
|
137
|
+
rows: task.rows,
|
|
138
|
+
workingDirectory: task.workingDirectory,
|
|
139
|
+
yieldTimeMs: task.yieldTimeMs,
|
|
140
|
+
timeoutMs: task.timeoutMs,
|
|
141
|
+
maxOutputTokens: task.maxOutputTokens,
|
|
142
|
+
}, context);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function batchTaskIsExclusive(task, capabilityBatchPolicy) {
|
|
147
|
+
if (task.operation === "bash.run")
|
|
148
|
+
return true;
|
|
149
|
+
if (task.operation !== "capability.run")
|
|
150
|
+
return false;
|
|
151
|
+
return capabilityBatchPolicy?.(task.name) === "serial";
|
|
152
|
+
}
|
|
153
|
+
function batchParentOutcome(summary) {
|
|
154
|
+
return summary.failed > 0
|
|
155
|
+
? { type: "failed", error: `${summary.failed} of ${summary.childCount} Batch tasks failed.` }
|
|
156
|
+
: { type: "succeeded" };
|
|
157
|
+
}
|
|
158
|
+
function taskClaims(root, task) {
|
|
159
|
+
switch (task.operation) {
|
|
160
|
+
case "read":
|
|
161
|
+
return [{ key: batchPathKey(root, task.path), mode: "read" }];
|
|
162
|
+
case "write":
|
|
163
|
+
case "edit":
|
|
164
|
+
case "delete":
|
|
165
|
+
return [{ key: batchPathKey(root, task.path), mode: "write" }];
|
|
166
|
+
case "rename":
|
|
167
|
+
return [
|
|
168
|
+
{ key: batchPathKey(root, task.path), mode: "write" },
|
|
169
|
+
{ key: batchPathKey(root, task.newPath), mode: "write" },
|
|
170
|
+
];
|
|
171
|
+
case "capability.run":
|
|
172
|
+
case "bash.run":
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function batchPathKey(root, path) {
|
|
177
|
+
const resolved = resolve(root, path);
|
|
178
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
179
|
+
}
|
|
180
|
+
function sanitizeChildResult(result) {
|
|
181
|
+
if (typeof result !== "object" || result === null) {
|
|
182
|
+
return { content: [{ type: "text", text: String(result ?? "") }] };
|
|
183
|
+
}
|
|
184
|
+
const record = result;
|
|
185
|
+
const content = Array.isArray(record.content) ? record.content : [];
|
|
186
|
+
const structuredContent = typeof record.structuredContent === "object" &&
|
|
187
|
+
record.structuredContent !== null &&
|
|
188
|
+
!Array.isArray(record.structuredContent)
|
|
189
|
+
? record.structuredContent
|
|
190
|
+
: undefined;
|
|
191
|
+
return {
|
|
192
|
+
content,
|
|
193
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
194
|
+
...(record.isError === true ? { isError: true } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function childFailureMessage(result) {
|
|
198
|
+
const sanitized = sanitizeChildResult(result);
|
|
199
|
+
const text = sanitized.content.flatMap((entry) => {
|
|
200
|
+
if (typeof entry !== "object" || entry === null)
|
|
201
|
+
return [];
|
|
202
|
+
const value = entry.text;
|
|
203
|
+
return typeof value === "string" ? [value] : [];
|
|
204
|
+
}).join("\n").trim();
|
|
205
|
+
return text || "Batch child returned an error result.";
|
|
206
|
+
}
|
|
207
|
+
function workspaceSnapshot(workspace) {
|
|
208
|
+
return {
|
|
209
|
+
id: workspace.id,
|
|
210
|
+
root: workspace.root,
|
|
211
|
+
mode: workspace.mode,
|
|
212
|
+
...(workspace.sourceRoot ? { sourceRoot: workspace.sourceRoot } : {}),
|
|
213
|
+
...(workspace.worktree?.branch ? { branch: workspace.worktree.branch } : {}),
|
|
214
|
+
...(workspace.worktree?.targetBranch ? { targetBranch: workspace.worktree.targetBranch } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
export class BatchScheduler {
|
|
3
|
+
async run(tasks, options = {}) {
|
|
4
|
+
validateTasks(tasks);
|
|
5
|
+
const concurrency = resolveConcurrency(tasks.length, options.concurrency);
|
|
6
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
7
|
+
signal.throwIfAborted();
|
|
8
|
+
const pending = new Set(tasks.map((_task, index) => index));
|
|
9
|
+
const active = new Map();
|
|
10
|
+
const results = new Array(tasks.length);
|
|
11
|
+
while (pending.size > 0 || active.size > 0) {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
this.startRunnable(tasks, pending, active, concurrency, signal);
|
|
14
|
+
if (active.size === 0) {
|
|
15
|
+
throw new Error("Batch scheduler could not make progress.");
|
|
16
|
+
}
|
|
17
|
+
const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
|
|
18
|
+
active.delete(settled.index);
|
|
19
|
+
if (signal.aborted) {
|
|
20
|
+
await Promise.allSettled([...active.values()].map((entry) => entry.promise));
|
|
21
|
+
throw abortReason(signal);
|
|
22
|
+
}
|
|
23
|
+
const task = tasks[settled.index];
|
|
24
|
+
results[settled.index] = settled.status === "done"
|
|
25
|
+
? { id: task.id, status: "done", value: settled.value }
|
|
26
|
+
: { id: task.id, status: "error", error: errorMessage(settled.error) };
|
|
27
|
+
}
|
|
28
|
+
return results.map((result, index) => {
|
|
29
|
+
if (!result)
|
|
30
|
+
throw new Error(`Batch task ${tasks[index]?.id ?? index} did not produce a result.`);
|
|
31
|
+
return result;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
startRunnable(tasks, pending, active, concurrency, signal) {
|
|
35
|
+
while (active.size < concurrency) {
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
const index = [...pending].find((candidate) => canStart(tasks[candidate], active));
|
|
38
|
+
if (index === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const task = tasks[index];
|
|
41
|
+
pending.delete(index);
|
|
42
|
+
const promise = Promise.resolve()
|
|
43
|
+
.then(() => task.run(signal))
|
|
44
|
+
.then((value) => ({ index, status: "done", value }))
|
|
45
|
+
.catch((error) => ({ index, status: "error", error }));
|
|
46
|
+
active.set(index, { task, promise });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validateTasks(tasks) {
|
|
51
|
+
if (tasks.length < 1 || tasks.length > 100) {
|
|
52
|
+
throw new Error(`Batch task count must be between 1 and 100; received ${tasks.length}.`);
|
|
53
|
+
}
|
|
54
|
+
const ids = new Set();
|
|
55
|
+
for (const task of tasks) {
|
|
56
|
+
if (!task.id.trim())
|
|
57
|
+
throw new Error("Batch task id must not be empty.");
|
|
58
|
+
if (ids.has(task.id))
|
|
59
|
+
throw new Error(`Batch task ids must be unique; duplicate id: ${task.id}.`);
|
|
60
|
+
ids.add(task.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function resolveConcurrency(taskCount, requested) {
|
|
64
|
+
const concurrency = requested ?? Math.min(taskCount, 10);
|
|
65
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) {
|
|
66
|
+
throw new Error(`Batch concurrency must be an integer between 1 and 10; received ${concurrency}.`);
|
|
67
|
+
}
|
|
68
|
+
return concurrency;
|
|
69
|
+
}
|
|
70
|
+
function canStart(task, active) {
|
|
71
|
+
if (task.exclusive)
|
|
72
|
+
return active.size === 0;
|
|
73
|
+
for (const entry of active.values()) {
|
|
74
|
+
if (entry.task.exclusive)
|
|
75
|
+
return false;
|
|
76
|
+
if (claimsConflict(task.claims, entry.task.claims))
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
function claimsConflict(left, right) {
|
|
82
|
+
for (const first of left) {
|
|
83
|
+
for (const second of right) {
|
|
84
|
+
if (first.mode === "read" && second.mode === "read")
|
|
85
|
+
continue;
|
|
86
|
+
if (pathsOverlap(first.key, second.key))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function pathsOverlap(left, right) {
|
|
93
|
+
if (left === right)
|
|
94
|
+
return true;
|
|
95
|
+
return isInside(left, right) || isInside(right, left);
|
|
96
|
+
}
|
|
97
|
+
function isInside(path, root) {
|
|
98
|
+
const rel = relative(root, path);
|
|
99
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
100
|
+
}
|
|
101
|
+
function abortReason(signal) {
|
|
102
|
+
return signal.reason instanceof Error
|
|
103
|
+
? signal.reason
|
|
104
|
+
: new Error(signal.reason === undefined ? "Batch execution was cancelled." : String(signal.reason));
|
|
105
|
+
}
|
|
106
|
+
function errorMessage(error) {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const taskId = z.string().min(1).max(128);
|
|
3
|
+
const path = z.string().min(1);
|
|
4
|
+
const capabilityName = z.string().regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/);
|
|
5
|
+
const editEntry = z.object({
|
|
6
|
+
oldText: z.string(),
|
|
7
|
+
newText: z.string(),
|
|
8
|
+
}).strict();
|
|
9
|
+
export const batchCoreTaskSchema = z.discriminatedUnion("operation", [
|
|
10
|
+
z.object({
|
|
11
|
+
id: taskId,
|
|
12
|
+
operation: z.literal("read"),
|
|
13
|
+
path,
|
|
14
|
+
offset: z.number().int().positive().optional(),
|
|
15
|
+
limit: z.number().int().positive().optional(),
|
|
16
|
+
}).strict(),
|
|
17
|
+
z.object({
|
|
18
|
+
id: taskId,
|
|
19
|
+
operation: z.literal("write"),
|
|
20
|
+
path,
|
|
21
|
+
content: z.string(),
|
|
22
|
+
}).strict(),
|
|
23
|
+
z.object({
|
|
24
|
+
id: taskId,
|
|
25
|
+
operation: z.literal("edit"),
|
|
26
|
+
path,
|
|
27
|
+
edits: z.array(editEntry).min(1),
|
|
28
|
+
}).strict(),
|
|
29
|
+
z.object({
|
|
30
|
+
id: taskId,
|
|
31
|
+
operation: z.literal("rename"),
|
|
32
|
+
path,
|
|
33
|
+
newPath: path,
|
|
34
|
+
}).strict(),
|
|
35
|
+
z.object({
|
|
36
|
+
id: taskId,
|
|
37
|
+
operation: z.literal("delete"),
|
|
38
|
+
path,
|
|
39
|
+
recursive: z.boolean().optional(),
|
|
40
|
+
}).strict(),
|
|
41
|
+
z.object({
|
|
42
|
+
id: taskId,
|
|
43
|
+
operation: z.literal("capability.run"),
|
|
44
|
+
name: capabilityName,
|
|
45
|
+
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
46
|
+
}).strict(),
|
|
47
|
+
z.object({
|
|
48
|
+
id: taskId,
|
|
49
|
+
operation: z.literal("bash.run"),
|
|
50
|
+
command: z.string().min(1),
|
|
51
|
+
tty: z.boolean().optional(),
|
|
52
|
+
columns: z.number().int().min(1).max(1_000).optional(),
|
|
53
|
+
rows: z.number().int().min(1).max(1_000).optional(),
|
|
54
|
+
workingDirectory: z.string().optional(),
|
|
55
|
+
yieldTimeMs: z.number().int().min(0).max(300_000).optional(),
|
|
56
|
+
timeoutMs: z.number().int().min(1).max(86_400_000).optional(),
|
|
57
|
+
maxOutputTokens: z.number().int().positive().max(100_000).optional(),
|
|
58
|
+
}).strict(),
|
|
59
|
+
]);
|
|
60
|
+
export const batchExecuteInputSchema = z.object({
|
|
61
|
+
tasks: z.array(batchCoreTaskSchema).min(1).max(100),
|
|
62
|
+
concurrency: z.number().int().min(1).max(10).optional(),
|
|
63
|
+
}).strict().superRefine((input, context) => {
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
for (const task of input.tasks) {
|
|
66
|
+
if (ids.has(task.id)) {
|
|
67
|
+
context.addIssue({
|
|
68
|
+
code: "custom",
|
|
69
|
+
path: ["tasks"],
|
|
70
|
+
message: `Batch task ids must be unique; duplicate id: ${task.id}.`,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
ids.add(task.id);
|
|
75
|
+
}
|
|
76
|
+
});
|