@akira-tl/forgerelay 0.8.2 → 0.8.3
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 +6 -0
- package/capabilities/workspace-tasks/GUIDE.md +29 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +82 -21
- package/dist/server.js +176 -5
- package/dist/subagents/sessions/capability.js +3 -0
- package/dist/workspace-tasks.js +351 -0
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.3] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `workspace.tasks` with private file-backed Task Lists per persistent Workspace, preserving Task state across restart, close/reopen, managed-worktree backing replacement, and Composite close while keeping Task data out of project Git contents.
|
|
12
|
+
|
|
7
13
|
## [0.8.2] - 2026-08-31
|
|
8
14
|
|
|
9
15
|
### Changed
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Workspace Tasks
|
|
2
|
+
|
|
3
|
+
`workspace.tasks` 维护当前 Workspace 自己的持久 Task Lists。它是轻量工作续接状态,不是执行队列、Subagent Session、Activity 或依赖图。
|
|
4
|
+
|
|
5
|
+
## 使用边界
|
|
6
|
+
|
|
7
|
+
- 只操作当前调用上下文中的 Workspace;参数中没有目标 `workspaceId`。
|
|
8
|
+
- Task state 由 ForgeRelay 保存在私有 state directory,不写入 checkout 或 managed worktree。
|
|
9
|
+
- 一个 Workspace 可以有多个 Task List;List 可为 `active` 或 `archived`。
|
|
10
|
+
- Task 状态只有 `pending`、`in_progress`、`completed`。`content` 保存继续工作真正需要的要求、阻塞点、结论或下一步,而不是日志或对话转录。
|
|
11
|
+
- Task/List ID 创建后保持稳定。完成 Task 不会删除它;删除必须显式执行。
|
|
12
|
+
|
|
13
|
+
## 操作
|
|
14
|
+
|
|
15
|
+
先用 `operation="get"` 读取当前 Task state。
|
|
16
|
+
|
|
17
|
+
List 操作:
|
|
18
|
+
|
|
19
|
+
- `list.create`:创建具名 List,可指定 `position`。
|
|
20
|
+
- `list.update`:修改 `name`、`state` 或 `position`;`state="archived"` 用于归档,改回 `active` 即重新激活。
|
|
21
|
+
- `list.delete`:显式删除 List 及其 Tasks。
|
|
22
|
+
|
|
23
|
+
Task 操作:
|
|
24
|
+
|
|
25
|
+
- `task.create`:在一个 List 中创建 Task;需要 `subject`,可提供 `content`、`status`、`position`。
|
|
26
|
+
- `task.update`:修改 `subject`、`content`、`status` 或 `position`;至少提供一个变更字段。
|
|
27
|
+
- `task.delete`:显式删除 Task。
|
|
28
|
+
|
|
29
|
+
Task 修改使用独立的 revision/fingerprint 域;它不改变 Workspace bootstrap `contextFingerprint`。
|
package/dist/capabilities.js
CHANGED
|
@@ -39,6 +39,11 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
|
|
|
39
39
|
description: "Read-only semantic code navigation backed by external Language servers.",
|
|
40
40
|
whenToRead: "Read before using code.intelligence or configuring Language servers.",
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
name: "workspace-tasks",
|
|
44
|
+
description: "Persistent Task Lists owned by the current Workspace.",
|
|
45
|
+
whenToRead: "Read before creating or maintaining Workspace Tasks.",
|
|
46
|
+
},
|
|
42
47
|
{
|
|
43
48
|
name: "batch-execution",
|
|
44
49
|
description: "One-call execution of multiple independent ForgeRelay core operations.",
|
|
@@ -87,6 +92,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
|
|
|
87
92
|
"hooks.lifecycle",
|
|
88
93
|
"capability-guides.read",
|
|
89
94
|
"code.intelligence",
|
|
95
|
+
"workspace.tasks",
|
|
90
96
|
];
|
|
91
97
|
if (config.toolMode !== "codex") {
|
|
92
98
|
capabilities.push("batch.execute");
|
|
@@ -130,6 +130,52 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
130
130
|
line: z.number().int(),
|
|
131
131
|
column: z.number().int(),
|
|
132
132
|
};
|
|
133
|
+
const workspaceTaskStatus = z.enum(["pending", "in_progress", "completed"]);
|
|
134
|
+
const workspaceTaskListState = z.enum(["active", "archived"]);
|
|
135
|
+
const workspaceTasksInput = z.union([
|
|
136
|
+
z.object({ operation: z.literal("get") }).strict(),
|
|
137
|
+
z.object({
|
|
138
|
+
operation: z.literal("list.create"),
|
|
139
|
+
name: z.string().trim().min(1),
|
|
140
|
+
position: z.number().int().min(0).optional(),
|
|
141
|
+
}).strict(),
|
|
142
|
+
z.object({
|
|
143
|
+
operation: z.literal("list.update"),
|
|
144
|
+
listId: z.string().min(1),
|
|
145
|
+
name: z.string().trim().min(1).optional(),
|
|
146
|
+
state: workspaceTaskListState.optional(),
|
|
147
|
+
position: z.number().int().min(0).optional(),
|
|
148
|
+
}).strict().refine((input) => input.name !== undefined || input.state !== undefined || input.position !== undefined, { message: "list.update requires at least one field to change" }),
|
|
149
|
+
z.object({
|
|
150
|
+
operation: z.literal("list.delete"),
|
|
151
|
+
listId: z.string().min(1),
|
|
152
|
+
}).strict(),
|
|
153
|
+
z.object({
|
|
154
|
+
operation: z.literal("task.create"),
|
|
155
|
+
listId: z.string().min(1),
|
|
156
|
+
subject: z.string().trim().min(1),
|
|
157
|
+
content: z.string().optional(),
|
|
158
|
+
status: workspaceTaskStatus.optional(),
|
|
159
|
+
position: z.number().int().min(0).optional(),
|
|
160
|
+
}).strict(),
|
|
161
|
+
z.object({
|
|
162
|
+
operation: z.literal("task.update"),
|
|
163
|
+
listId: z.string().min(1),
|
|
164
|
+
taskId: z.string().min(1),
|
|
165
|
+
subject: z.string().trim().min(1).optional(),
|
|
166
|
+
content: z.string().optional(),
|
|
167
|
+
status: workspaceTaskStatus.optional(),
|
|
168
|
+
position: z.number().int().min(0).optional(),
|
|
169
|
+
}).strict().refine((input) => input.subject !== undefined
|
|
170
|
+
|| input.content !== undefined
|
|
171
|
+
|| input.status !== undefined
|
|
172
|
+
|| input.position !== undefined, { message: "task.update requires at least one field to change" }),
|
|
173
|
+
z.object({
|
|
174
|
+
operation: z.literal("task.delete"),
|
|
175
|
+
listId: z.string().min(1),
|
|
176
|
+
taskId: z.string().min(1),
|
|
177
|
+
}).strict(),
|
|
178
|
+
]);
|
|
133
179
|
const subagentSessionInput = z.discriminatedUnion("operation", [
|
|
134
180
|
z.object({
|
|
135
181
|
operation: z.literal("start"),
|
|
@@ -190,11 +236,11 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
190
236
|
readGuideBeforeFirstUse: true,
|
|
191
237
|
batchPolicy: "parallel",
|
|
192
238
|
inputSchema: hooksCheckInput,
|
|
193
|
-
availability: () => (
|
|
239
|
+
availability: (context) => filesystemWorkspaceAvailability(context),
|
|
194
240
|
run: async (_input, context) => ({
|
|
195
241
|
value: {
|
|
196
242
|
ok: true,
|
|
197
|
-
...await dependencies.inspectHooks(context
|
|
243
|
+
...await dependencies.inspectHooks(requireWorkspaceRoot(context)),
|
|
198
244
|
},
|
|
199
245
|
}),
|
|
200
246
|
},
|
|
@@ -206,10 +252,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
206
252
|
readGuideBeforeFirstUse: true,
|
|
207
253
|
batchPolicy: "serial",
|
|
208
254
|
inputSchema: z.object({}).strict(),
|
|
209
|
-
availability: () => (
|
|
210
|
-
available: dependencies.reviewChanges?.available ?? false,
|
|
211
|
-
reason: dependencies.reviewChanges?.unavailableReason,
|
|
212
|
-
}),
|
|
255
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.reviewChanges?.available ?? false, dependencies.reviewChanges?.unavailableReason),
|
|
213
256
|
run: async (_input, context) => dependencies.reviewChanges.run(context),
|
|
214
257
|
}]
|
|
215
258
|
: []),
|
|
@@ -221,11 +264,23 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
221
264
|
readGuideBeforeFirstUse: true,
|
|
222
265
|
batchPolicy: "parallel",
|
|
223
266
|
inputSchema: codeIntelligenceInput,
|
|
267
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.codeIntelligence?.available ?? false, dependencies.codeIntelligence?.unavailableReason),
|
|
268
|
+
run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
|
|
269
|
+
}]
|
|
270
|
+
: []),
|
|
271
|
+
...(dependencies.workspaceTasks
|
|
272
|
+
? [{
|
|
273
|
+
name: "workspace.tasks",
|
|
274
|
+
description: "Maintain persistent Task Lists owned by the current Workspace.",
|
|
275
|
+
guideName: "workspace-tasks",
|
|
276
|
+
readGuideBeforeFirstUse: true,
|
|
277
|
+
batchPolicy: "serial",
|
|
278
|
+
inputSchema: workspaceTasksInput,
|
|
224
279
|
availability: () => ({
|
|
225
|
-
available: dependencies.
|
|
226
|
-
reason: dependencies.
|
|
280
|
+
available: dependencies.workspaceTasks?.available ?? false,
|
|
281
|
+
reason: dependencies.workspaceTasks?.unavailableReason,
|
|
227
282
|
}),
|
|
228
|
-
run: async (input, context, options) => dependencies.
|
|
283
|
+
run: async (input, context, options) => dependencies.workspaceTasks.run(input, context, options),
|
|
229
284
|
}]
|
|
230
285
|
: []),
|
|
231
286
|
...(dependencies.subagentSession
|
|
@@ -236,10 +291,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
236
291
|
readGuideBeforeFirstUse: true,
|
|
237
292
|
batchPolicy: "unsupported",
|
|
238
293
|
inputSchema: subagentSessionInput,
|
|
239
|
-
availability: () => (
|
|
240
|
-
available: dependencies.subagentSession?.available ?? false,
|
|
241
|
-
reason: dependencies.subagentSession?.unavailableReason,
|
|
242
|
-
}),
|
|
294
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.subagentSession?.available ?? false, dependencies.subagentSession?.unavailableReason),
|
|
243
295
|
run: async (input, context, options) => dependencies.subagentSession.run(input, context, options),
|
|
244
296
|
}]
|
|
245
297
|
: []),
|
|
@@ -251,10 +303,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
251
303
|
readGuideBeforeFirstUse: true,
|
|
252
304
|
batchPolicy: "unsupported",
|
|
253
305
|
inputSchema: batchExecuteInputSchema,
|
|
254
|
-
availability: () => (
|
|
255
|
-
available: dependencies.batchExecute?.available ?? false,
|
|
256
|
-
reason: dependencies.batchExecute?.unavailableReason,
|
|
257
|
-
}),
|
|
306
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.batchExecute?.available ?? false, dependencies.batchExecute?.unavailableReason),
|
|
258
307
|
run: async (input, context, options) => dependencies.batchExecute.run(input, context, options),
|
|
259
308
|
}]
|
|
260
309
|
: []),
|
|
@@ -277,15 +326,27 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
277
326
|
path: z.string().min(1),
|
|
278
327
|
}).strict(),
|
|
279
328
|
nativeFileArgument: "file",
|
|
280
|
-
availability: () => (
|
|
281
|
-
available: dependencies.downloadArtifact?.available ?? false,
|
|
282
|
-
reason: dependencies.downloadArtifact?.unavailableReason,
|
|
283
|
-
}),
|
|
329
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.downloadArtifact?.available ?? false, dependencies.downloadArtifact?.unavailableReason),
|
|
284
330
|
run: async (input, context) => dependencies.downloadArtifact.run(input, context),
|
|
285
331
|
}]
|
|
286
332
|
: []),
|
|
287
333
|
]);
|
|
288
334
|
}
|
|
335
|
+
function filesystemWorkspaceAvailability(context, available = true, reason) {
|
|
336
|
+
if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
|
|
337
|
+
return {
|
|
338
|
+
available: false,
|
|
339
|
+
reason: "This capability requires a filesystem-backed Workspace.",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { available, ...(reason ? { reason } : {}) };
|
|
343
|
+
}
|
|
344
|
+
function requireWorkspaceRoot(context) {
|
|
345
|
+
if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
|
|
346
|
+
throw new CapabilityError("capability_unavailable", "This capability requires a filesystem-backed Workspace.");
|
|
347
|
+
}
|
|
348
|
+
return context.workspaceRoot;
|
|
349
|
+
}
|
|
289
350
|
function isRecord(value) {
|
|
290
351
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
291
352
|
}
|
package/dist/server.js
CHANGED
|
@@ -21,7 +21,7 @@ import { HostTurnStore } from "./activity/host-turn-store.js";
|
|
|
21
21
|
import { registerActivityQueryTools } from "./activity/mcp-query-tools.js";
|
|
22
22
|
import { ActivityLifecycle, } from "./activity/lifecycle.js";
|
|
23
23
|
import { ActivityQueryService } from "./activity/query-service.js";
|
|
24
|
-
import { buildCapabilityFingerprint } from "./capabilities.js";
|
|
24
|
+
import { buildCapabilityFingerprint, loadCapabilityGuides } from "./capabilities.js";
|
|
25
25
|
import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
|
|
26
26
|
import { deletePath, renamePath } from "./file-mutations.js";
|
|
27
27
|
import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
|
|
@@ -53,6 +53,7 @@ import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP
|
|
|
53
53
|
import { shutdownHttpServer } from "./server-shutdown.js";
|
|
54
54
|
import { formatPathForPrompt } from "./skills.js";
|
|
55
55
|
import { createWorkspaceStore } from "./workspace-store.js";
|
|
56
|
+
import { WorkspaceTaskStore } from "./workspace-tasks.js";
|
|
56
57
|
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
|
|
57
58
|
import { formatAvailableSubagentProfile, summarizeSubagentProfile } from "./subagents/profiles.js";
|
|
58
59
|
import { formatSubagentProviderAvailabilitySummary, formatUnavailableSubagentProvider, getSubagentProviderAvailabilitySnapshot, } from "./subagents/providers/availability.js";
|
|
@@ -821,6 +822,7 @@ function workspaceHookInvocation(workspace) {
|
|
|
821
822
|
function capabilityContextFor(workspace) {
|
|
822
823
|
return {
|
|
823
824
|
workspaceId: workspace.id,
|
|
825
|
+
workspaceKind: "workspace",
|
|
824
826
|
workspaceRoot: workspace.root,
|
|
825
827
|
guides: workspace.capabilityGuides.map((guide) => ({
|
|
826
828
|
name: guide.name,
|
|
@@ -830,6 +832,56 @@ function capabilityContextFor(workspace) {
|
|
|
830
832
|
})),
|
|
831
833
|
};
|
|
832
834
|
}
|
|
835
|
+
function compositeCapabilityContext(workspaceId, guides) {
|
|
836
|
+
return {
|
|
837
|
+
workspaceId,
|
|
838
|
+
workspaceKind: "composite",
|
|
839
|
+
guides: guides.map((guide) => ({
|
|
840
|
+
name: guide.name,
|
|
841
|
+
description: guide.description,
|
|
842
|
+
whenToRead: guide.whenToRead,
|
|
843
|
+
path: formatPathForPrompt(guide.filePath),
|
|
844
|
+
})),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function requireCapabilityWorkspaceRoot(context) {
|
|
848
|
+
if (!context.workspaceRoot) {
|
|
849
|
+
throw new CapabilityError("capability_unavailable", `Capability execution requires a filesystem-backed Workspace; ${context.workspaceId} is ${context.workspaceKind}.`);
|
|
850
|
+
}
|
|
851
|
+
return context.workspaceRoot;
|
|
852
|
+
}
|
|
853
|
+
function runWorkspaceTasksCapability(store, workspaceId, input) {
|
|
854
|
+
switch (input.operation) {
|
|
855
|
+
case "get":
|
|
856
|
+
return store.read(workspaceId);
|
|
857
|
+
case "list.create":
|
|
858
|
+
return store.createList(workspaceId, { name: input.name, position: input.position });
|
|
859
|
+
case "list.update":
|
|
860
|
+
return store.updateList(workspaceId, input.listId, {
|
|
861
|
+
name: input.name,
|
|
862
|
+
state: input.state,
|
|
863
|
+
position: input.position,
|
|
864
|
+
});
|
|
865
|
+
case "list.delete":
|
|
866
|
+
return store.deleteList(workspaceId, input.listId);
|
|
867
|
+
case "task.create":
|
|
868
|
+
return store.createTask(workspaceId, input.listId, {
|
|
869
|
+
subject: input.subject,
|
|
870
|
+
content: input.content,
|
|
871
|
+
status: input.status,
|
|
872
|
+
position: input.position,
|
|
873
|
+
});
|
|
874
|
+
case "task.update":
|
|
875
|
+
return store.updateTask(workspaceId, input.listId, input.taskId, {
|
|
876
|
+
subject: input.subject,
|
|
877
|
+
content: input.content,
|
|
878
|
+
status: input.status,
|
|
879
|
+
position: input.position,
|
|
880
|
+
});
|
|
881
|
+
case "task.delete":
|
|
882
|
+
return store.deleteTask(workspaceId, input.listId, input.taskId);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
833
885
|
async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
|
|
834
886
|
return reviewCheckpoints.reviewChanges({
|
|
835
887
|
workspaceId: workspace.id,
|
|
@@ -1192,6 +1244,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1192
1244
|
const connectionScopeId = `mcp-connection:${randomUUID()}`;
|
|
1193
1245
|
const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
|
|
1194
1246
|
const compositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
|
|
1247
|
+
const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
|
|
1248
|
+
const compositeTaskGuides = loadCapabilityGuides(config).filter((guide) => guide.name === "workspace-tasks");
|
|
1195
1249
|
const compositeActivity = new CompositeActivityCoordinator(compositeWorkspaces, activityQueries, remoteWorkspaces);
|
|
1196
1250
|
const resolveExecutionTarget = (workspaceId, memberName) => {
|
|
1197
1251
|
if (!compositeWorkspaces.has(workspaceId)) {
|
|
@@ -1248,6 +1302,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1248
1302
|
const capabilityRegistry = createCapabilityRegistry({
|
|
1249
1303
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
1250
1304
|
...subagentMcp.registryDependencies,
|
|
1305
|
+
workspaceTasks: {
|
|
1306
|
+
available: true,
|
|
1307
|
+
run: async (input, context) => ({
|
|
1308
|
+
value: runWorkspaceTasksCapability(workspaceTasks, context.workspaceId, input),
|
|
1309
|
+
}),
|
|
1310
|
+
},
|
|
1251
1311
|
batchExecute: {
|
|
1252
1312
|
available: batchExecuteAvailable,
|
|
1253
1313
|
unavailableReason: batchExecuteAvailable
|
|
@@ -1270,7 +1330,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1270
1330
|
run: async (input, context, options) => {
|
|
1271
1331
|
try {
|
|
1272
1332
|
return {
|
|
1273
|
-
value: await codeIntelligence.run(context
|
|
1333
|
+
value: await codeIntelligence.run(requireCapabilityWorkspaceRoot(context), input, { signal: options.signal }),
|
|
1274
1334
|
};
|
|
1275
1335
|
}
|
|
1276
1336
|
catch (error) {
|
|
@@ -1289,7 +1349,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1289
1349
|
run: async (context) => {
|
|
1290
1350
|
const review = await reviewWorkspaceChanges(reviewCheckpoints, {
|
|
1291
1351
|
id: context.workspaceId,
|
|
1292
|
-
root: context
|
|
1352
|
+
root: requireCapabilityWorkspaceRoot(context),
|
|
1293
1353
|
});
|
|
1294
1354
|
return {
|
|
1295
1355
|
value: {
|
|
@@ -1317,7 +1377,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1317
1377
|
const downloaded = await downloadIncomingArtifact({
|
|
1318
1378
|
registry: incomingArtifactRegistry,
|
|
1319
1379
|
workspaceId: context.workspaceId,
|
|
1320
|
-
workspaceRoot: context
|
|
1380
|
+
workspaceRoot: requireCapabilityWorkspaceRoot(context),
|
|
1321
1381
|
maxFileBytes: config.artifactMaxFileBytes,
|
|
1322
1382
|
file: input.file,
|
|
1323
1383
|
path: input.path,
|
|
@@ -2398,6 +2458,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2398
2458
|
const composite = workspaceId !== undefined
|
|
2399
2459
|
? compositeWorkspaces.open(workspaceId)
|
|
2400
2460
|
: compositeWorkspaces.create(name ?? "");
|
|
2461
|
+
workspaceTasks.initializeWorkspace(composite.id);
|
|
2462
|
+
const compositeTaskContext = compositeCapabilityContext(composite.id, compositeTaskGuides);
|
|
2463
|
+
const compositeCapabilityCatalog = capabilityRegistry.catalog(compositeTaskContext);
|
|
2464
|
+
const compositeCapabilityGuides = compositeTaskGuides.map((guide) => ({
|
|
2465
|
+
name: guide.name,
|
|
2466
|
+
description: guide.description,
|
|
2467
|
+
whenToRead: guide.whenToRead,
|
|
2468
|
+
path: formatPathForPrompt(guide.filePath),
|
|
2469
|
+
}));
|
|
2401
2470
|
const memberContext = memberName
|
|
2402
2471
|
? await loadCompositeMemberContext(composite.id, memberName, context ?? "auto", conversationScopeId, protectedWorkspaceIds)
|
|
2403
2472
|
: undefined;
|
|
@@ -2408,6 +2477,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2408
2477
|
? `Members: ${composite.members.map((member) => `${member.name} — ${member.purpose}`).join("; ")}.`
|
|
2409
2478
|
: "This Composite Workspace currently has no members.",
|
|
2410
2479
|
"Member names and purposes are structural context and are always returned when this Composite Workspace is opened. context=auto/full/none controls only heavy member bootstrap context, not this Composite identity.",
|
|
2480
|
+
compositeCapabilityCatalog.length > 0
|
|
2481
|
+
? `Composite-owned capabilities: ${compositeCapabilityCatalog.map((entry) => entry.name).join(", ")}. Use these without member because their state belongs to the Composite Workspace itself.`
|
|
2482
|
+
: undefined,
|
|
2411
2483
|
composite.members.length > 0
|
|
2412
2484
|
? "Before first work on a member, reopen this Composite Workspace with memberName=<member> and context=auto to receive that member's project bootstrap without creating an implicit current member."
|
|
2413
2485
|
: undefined,
|
|
@@ -2435,6 +2507,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2435
2507
|
status: composite.status,
|
|
2436
2508
|
state: composite.status,
|
|
2437
2509
|
members: composite.members,
|
|
2510
|
+
capabilityCatalog: compositeCapabilityCatalog,
|
|
2511
|
+
capabilityGuides: compositeCapabilityGuides,
|
|
2438
2512
|
...(memberContext ? { memberContext } : {}),
|
|
2439
2513
|
instruction,
|
|
2440
2514
|
},
|
|
@@ -2547,6 +2621,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2547
2621
|
conversationScopeId,
|
|
2548
2622
|
protectedWorkspaceIds,
|
|
2549
2623
|
});
|
|
2624
|
+
workspaceTasks.initializeWorkspace(workspace.id);
|
|
2550
2625
|
const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
|
|
2551
2626
|
const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
|
|
2552
2627
|
const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
|
|
@@ -2808,6 +2883,74 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2808
2883
|
openWorldHint: true,
|
|
2809
2884
|
},
|
|
2810
2885
|
}, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
|
|
2886
|
+
if (name === "workspace.tasks" && compositeWorkspaces.has(workspaceId)) {
|
|
2887
|
+
if (member !== undefined) {
|
|
2888
|
+
throw new Error(`workspace.tasks belongs to Composite Workspace ${workspaceId} itself and does not accept member.`);
|
|
2889
|
+
}
|
|
2890
|
+
if (!compositeWorkspaces.isActive(workspaceId)) {
|
|
2891
|
+
throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
|
|
2892
|
+
}
|
|
2893
|
+
const startedAt = performance.now();
|
|
2894
|
+
const context = compositeCapabilityContext(workspaceId, compositeTaskGuides);
|
|
2895
|
+
try {
|
|
2896
|
+
if (action === "run") {
|
|
2897
|
+
const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, context, {
|
|
2898
|
+
nativeFile: file,
|
|
2899
|
+
signal: extra.signal,
|
|
2900
|
+
requestMeta: extra._meta,
|
|
2901
|
+
sessionId: extra.sessionId,
|
|
2902
|
+
});
|
|
2903
|
+
const result = {
|
|
2904
|
+
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
|
|
2905
|
+
structuredContent: { name, action, result: execution.value },
|
|
2906
|
+
};
|
|
2907
|
+
logToolCall(config, {
|
|
2908
|
+
tool: toolNames.capability,
|
|
2909
|
+
capability: name,
|
|
2910
|
+
action,
|
|
2911
|
+
success: true,
|
|
2912
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2913
|
+
});
|
|
2914
|
+
return result;
|
|
2915
|
+
}
|
|
2916
|
+
const capability = capabilityRegistry.describe(name, context);
|
|
2917
|
+
const result = {
|
|
2918
|
+
content: [textBlock([
|
|
2919
|
+
`${capability.name}: ${capability.description}`,
|
|
2920
|
+
`Available: ${capability.available}`,
|
|
2921
|
+
`Guide: ${capability.guide.path}`,
|
|
2922
|
+
capability.guide.readBeforeFirstUse
|
|
2923
|
+
? "Read the guide before first use when this contract is unfamiliar."
|
|
2924
|
+
: undefined,
|
|
2925
|
+
].filter(Boolean).join("\n"))],
|
|
2926
|
+
structuredContent: { name, action, capability },
|
|
2927
|
+
};
|
|
2928
|
+
logToolCall(config, {
|
|
2929
|
+
tool: toolNames.capability,
|
|
2930
|
+
capability: name,
|
|
2931
|
+
action,
|
|
2932
|
+
success: true,
|
|
2933
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2934
|
+
});
|
|
2935
|
+
return result;
|
|
2936
|
+
}
|
|
2937
|
+
catch (error) {
|
|
2938
|
+
if (extra.signal.aborted)
|
|
2939
|
+
throw error;
|
|
2940
|
+
const capabilityError = error instanceof CapabilityError
|
|
2941
|
+
? error
|
|
2942
|
+
: new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
|
|
2943
|
+
return {
|
|
2944
|
+
content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
|
|
2945
|
+
structuredContent: {
|
|
2946
|
+
name,
|
|
2947
|
+
action,
|
|
2948
|
+
error: { code: capabilityError.code, message: capabilityError.message },
|
|
2949
|
+
},
|
|
2950
|
+
isError: true,
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2811
2954
|
const target = resolveExecutionTarget(workspaceId, member);
|
|
2812
2955
|
const executionWorkspaceId = target.executionWorkspaceId;
|
|
2813
2956
|
const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
|
|
@@ -2972,6 +3115,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2972
3115
|
const composite = action === "delete"
|
|
2973
3116
|
? compositeWorkspaces.dissolve(workspaceId)
|
|
2974
3117
|
: compositeWorkspaces.close(workspaceId);
|
|
3118
|
+
if (action === "delete")
|
|
3119
|
+
workspaceTasks.deleteWorkspace(workspaceId);
|
|
2975
3120
|
compositeActivity.forgetComposite(workspaceId);
|
|
2976
3121
|
workspacePanelStates.delete(workspaceId);
|
|
2977
3122
|
const result = [
|
|
@@ -3038,6 +3183,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3038
3183
|
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
3039
3184
|
operation: async () => {
|
|
3040
3185
|
workspaces.deleteWorkspace(session.id);
|
|
3186
|
+
workspaceTasks.deleteWorkspace(session.id);
|
|
3041
3187
|
await reviewCheckpoints.releaseWorkspace(session.id);
|
|
3042
3188
|
const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
|
|
3043
3189
|
return {
|
|
@@ -3080,6 +3226,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3080
3226
|
payload: { workspaceId: session.id, action: "delete", mode: session.mode },
|
|
3081
3227
|
operation: async () => {
|
|
3082
3228
|
workspaces.deleteWorkspace(session.id);
|
|
3229
|
+
workspaceTasks.deleteWorkspace(session.id);
|
|
3083
3230
|
await reviewCheckpoints.releaseWorkspace(session.id);
|
|
3084
3231
|
const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
|
|
3085
3232
|
return {
|
|
@@ -3142,6 +3289,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3142
3289
|
await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
|
|
3143
3290
|
if (action === "delete") {
|
|
3144
3291
|
workspaces.deleteWorkspace(workspace.id);
|
|
3292
|
+
workspaceTasks.deleteWorkspace(workspace.id);
|
|
3145
3293
|
}
|
|
3146
3294
|
const result = [
|
|
3147
3295
|
action === "delete"
|
|
@@ -3237,7 +3385,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3237
3385
|
member: z
|
|
3238
3386
|
.string()
|
|
3239
3387
|
.optional()
|
|
3240
|
-
.describe("Required for
|
|
3388
|
+
.describe("Required for Composite member-scoped file reads. Omit only when reading an advertised Composite-owned capability guide."),
|
|
3241
3389
|
path: z
|
|
3242
3390
|
.string()
|
|
3243
3391
|
.optional()
|
|
@@ -3279,6 +3427,29 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
3279
3427
|
if ((path === undefined) === (paths === undefined)) {
|
|
3280
3428
|
throw new Error("read requires exactly one of path or paths.");
|
|
3281
3429
|
}
|
|
3430
|
+
if (compositeWorkspaces.has(workspaceId) && member === undefined && path !== undefined) {
|
|
3431
|
+
const guide = compositeTaskGuides.find((candidate) => formatPathForPrompt(candidate.filePath) === path || candidate.filePath === path);
|
|
3432
|
+
if (guide) {
|
|
3433
|
+
if (!compositeWorkspaces.isActive(workspaceId)) {
|
|
3434
|
+
throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
|
|
3435
|
+
}
|
|
3436
|
+
const startedAt = performance.now();
|
|
3437
|
+
const raw = readFileSync(guide.filePath, "utf8");
|
|
3438
|
+
const start = (offset ?? 1) - 1;
|
|
3439
|
+
const end = limit === undefined ? undefined : start + limit;
|
|
3440
|
+
const result = raw.split("\n").slice(start, end).join("\n");
|
|
3441
|
+
logToolCall(config, {
|
|
3442
|
+
tool: toolNames.read,
|
|
3443
|
+
path: formatPathForPrompt(guide.filePath),
|
|
3444
|
+
success: true,
|
|
3445
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
3446
|
+
});
|
|
3447
|
+
return {
|
|
3448
|
+
content: [textBlock(result)],
|
|
3449
|
+
structuredContent: { result },
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3282
3453
|
const target = resolveExecutionTarget(workspaceId, member);
|
|
3283
3454
|
const executionWorkspaceId = target.executionWorkspaceId;
|
|
3284
3455
|
const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
|
|
@@ -19,6 +19,9 @@ export class SubagentSessionCapability {
|
|
|
19
19
|
this.ownerAliveOverride = options.ownerAlive;
|
|
20
20
|
}
|
|
21
21
|
async run(input, context, options) {
|
|
22
|
+
if (!context.workspaceRoot) {
|
|
23
|
+
throw new CapabilityError("capability_unavailable", "subagent.session requires a filesystem-backed Workspace.");
|
|
24
|
+
}
|
|
22
25
|
const manager = new SubagentSessionManager(this.config, {
|
|
23
26
|
launch: (request) => this.launch(request),
|
|
24
27
|
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as z from "zod/v4";
|
|
5
|
+
const TASK_STATE_VERSION = 1;
|
|
6
|
+
const MAX_TASK_STATE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_TASK_LISTS = 100;
|
|
8
|
+
const MAX_TASKS_PER_LIST = 500;
|
|
9
|
+
const MAX_LIST_NAME_LENGTH = 120;
|
|
10
|
+
const MAX_TASK_SUBJECT_LENGTH = 240;
|
|
11
|
+
const MAX_TASK_CONTENT_LENGTH = 64 * 1024;
|
|
12
|
+
const workspaceTaskSchema = z.object({
|
|
13
|
+
id: z.string().regex(/^tsk_[a-f0-9]{10}$/),
|
|
14
|
+
status: z.enum(["pending", "in_progress", "completed"]),
|
|
15
|
+
subject: z.string().min(1).max(MAX_TASK_SUBJECT_LENGTH),
|
|
16
|
+
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
|
|
17
|
+
}).strict();
|
|
18
|
+
const workspaceTaskListSchema = z.object({
|
|
19
|
+
id: z.string().regex(/^tl_[a-f0-9]{10}$/),
|
|
20
|
+
name: z.string().min(1).max(MAX_LIST_NAME_LENGTH),
|
|
21
|
+
state: z.enum(["active", "archived"]),
|
|
22
|
+
revision: z.number().int().positive(),
|
|
23
|
+
tasks: z.array(workspaceTaskSchema).max(MAX_TASKS_PER_LIST),
|
|
24
|
+
}).strict();
|
|
25
|
+
const workspaceTaskStateSchema = z.object({
|
|
26
|
+
version: z.literal(TASK_STATE_VERSION),
|
|
27
|
+
revision: z.number().int().nonnegative(),
|
|
28
|
+
lists: z.array(workspaceTaskListSchema).max(MAX_TASK_LISTS),
|
|
29
|
+
}).strict().superRefine((state, context) => {
|
|
30
|
+
const listIds = new Set();
|
|
31
|
+
const taskIds = new Set();
|
|
32
|
+
state.lists.forEach((list, listIndex) => {
|
|
33
|
+
if (listIds.has(list.id)) {
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
path: ["lists", listIndex, "id"],
|
|
37
|
+
message: `Duplicate Task List id ${list.id}.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
listIds.add(list.id);
|
|
41
|
+
list.tasks.forEach((task, taskIndex) => {
|
|
42
|
+
if (taskIds.has(task.id)) {
|
|
43
|
+
context.addIssue({
|
|
44
|
+
code: "custom",
|
|
45
|
+
path: ["lists", listIndex, "tasks", taskIndex, "id"],
|
|
46
|
+
message: `Duplicate Task id ${task.id}.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
taskIds.add(task.id);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
export class WorkspaceTaskStore {
|
|
54
|
+
stateDir;
|
|
55
|
+
constructor(stateDir) {
|
|
56
|
+
this.stateDir = stateDir;
|
|
57
|
+
}
|
|
58
|
+
ensureWorkspace(workspaceId) {
|
|
59
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
60
|
+
const loaded = this.tryReadState(id);
|
|
61
|
+
if (loaded)
|
|
62
|
+
return snapshot(loaded.state, loaded.fingerprint);
|
|
63
|
+
return this.writeState(id, emptyState());
|
|
64
|
+
}
|
|
65
|
+
initializeWorkspace(workspaceId) {
|
|
66
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
67
|
+
const directory = this.workspaceStateDir(id);
|
|
68
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(this.statePath(id), `${JSON.stringify(emptyState(), null, 2)}\n`, {
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
flag: "wx",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!isErrno(error, "EEXIST"))
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
read(workspaceId) {
|
|
81
|
+
return this.ensureWorkspace(workspaceId);
|
|
82
|
+
}
|
|
83
|
+
createList(workspaceId, input) {
|
|
84
|
+
return this.mutate(workspaceId, (state) => {
|
|
85
|
+
if (state.lists.length >= MAX_TASK_LISTS) {
|
|
86
|
+
throw new Error(`Workspace Task List limit is ${MAX_TASK_LISTS}.`);
|
|
87
|
+
}
|
|
88
|
+
const list = {
|
|
89
|
+
id: `tl_${randomBytes(5).toString("hex")}`,
|
|
90
|
+
name: normalizeListName(input.name),
|
|
91
|
+
state: "active",
|
|
92
|
+
revision: 1,
|
|
93
|
+
tasks: [],
|
|
94
|
+
};
|
|
95
|
+
const position = normalizeInsertPosition(input.position, state.lists.length, "Task List");
|
|
96
|
+
state.lists.splice(position, 0, list);
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
updateList(workspaceId, listId, input) {
|
|
101
|
+
return this.mutate(workspaceId, (state) => {
|
|
102
|
+
const index = requireListIndex(state, listId);
|
|
103
|
+
const list = state.lists[index];
|
|
104
|
+
const nextName = input.name === undefined ? list.name : normalizeListName(input.name);
|
|
105
|
+
const nextState = input.state ?? list.state;
|
|
106
|
+
const nextPosition = input.position === undefined
|
|
107
|
+
? index
|
|
108
|
+
: normalizeMovePosition(input.position, state.lists.length, "Task List");
|
|
109
|
+
const metadataChanged = nextName !== list.name || nextState !== list.state;
|
|
110
|
+
const positionChanged = nextPosition !== index;
|
|
111
|
+
if (!metadataChanged && !positionChanged)
|
|
112
|
+
return false;
|
|
113
|
+
list.name = nextName;
|
|
114
|
+
list.state = nextState;
|
|
115
|
+
list.revision += 1;
|
|
116
|
+
if (positionChanged)
|
|
117
|
+
moveArrayEntry(state.lists, index, nextPosition);
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
deleteList(workspaceId, listId) {
|
|
122
|
+
return this.mutate(workspaceId, (state) => {
|
|
123
|
+
state.lists.splice(requireListIndex(state, listId), 1);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
createTask(workspaceId, listId, input) {
|
|
128
|
+
return this.mutate(workspaceId, (state) => {
|
|
129
|
+
const list = requireList(state, listId);
|
|
130
|
+
if (list.tasks.length >= MAX_TASKS_PER_LIST) {
|
|
131
|
+
throw new Error(`Task limit per Task List is ${MAX_TASKS_PER_LIST}.`);
|
|
132
|
+
}
|
|
133
|
+
const task = {
|
|
134
|
+
id: `tsk_${randomBytes(5).toString("hex")}`,
|
|
135
|
+
status: input.status ?? "pending",
|
|
136
|
+
subject: normalizeTaskSubject(input.subject),
|
|
137
|
+
content: normalizeTaskContent(input.content ?? ""),
|
|
138
|
+
};
|
|
139
|
+
const position = normalizeInsertPosition(input.position, list.tasks.length, "Task");
|
|
140
|
+
list.tasks.splice(position, 0, task);
|
|
141
|
+
list.revision += 1;
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
updateTask(workspaceId, listId, taskId, input) {
|
|
146
|
+
return this.mutate(workspaceId, (state) => {
|
|
147
|
+
const list = requireList(state, listId);
|
|
148
|
+
const index = requireTaskIndex(list, taskId);
|
|
149
|
+
const task = list.tasks[index];
|
|
150
|
+
const nextStatus = input.status ?? task.status;
|
|
151
|
+
const nextSubject = input.subject === undefined ? task.subject : normalizeTaskSubject(input.subject);
|
|
152
|
+
const nextContent = input.content === undefined ? task.content : normalizeTaskContent(input.content);
|
|
153
|
+
const nextPosition = input.position === undefined
|
|
154
|
+
? index
|
|
155
|
+
: normalizeMovePosition(input.position, list.tasks.length, "Task");
|
|
156
|
+
const fieldsChanged = nextStatus !== task.status || nextSubject !== task.subject || nextContent !== task.content;
|
|
157
|
+
const positionChanged = nextPosition !== index;
|
|
158
|
+
if (!fieldsChanged && !positionChanged)
|
|
159
|
+
return false;
|
|
160
|
+
task.status = nextStatus;
|
|
161
|
+
task.subject = nextSubject;
|
|
162
|
+
task.content = nextContent;
|
|
163
|
+
if (positionChanged)
|
|
164
|
+
moveArrayEntry(list.tasks, index, nextPosition);
|
|
165
|
+
list.revision += 1;
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
deleteTask(workspaceId, listId, taskId) {
|
|
170
|
+
return this.mutate(workspaceId, (state) => {
|
|
171
|
+
const list = requireList(state, listId);
|
|
172
|
+
list.tasks.splice(requireTaskIndex(list, taskId), 1);
|
|
173
|
+
list.revision += 1;
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
deleteWorkspace(workspaceId) {
|
|
178
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
179
|
+
rmSync(this.statePath(id), { force: true });
|
|
180
|
+
try {
|
|
181
|
+
rmdirSync(this.workspaceStateDir(id));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY") && !isErrno(error, "EEXIST")) {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
mutate(workspaceId, mutateState) {
|
|
190
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
191
|
+
const loaded = this.tryReadState(id);
|
|
192
|
+
const state = loaded ? cloneState(loaded.state) : emptyState();
|
|
193
|
+
if (!mutateState(state)) {
|
|
194
|
+
return loaded
|
|
195
|
+
? snapshot(loaded.state, loaded.fingerprint)
|
|
196
|
+
: this.writeState(id, state);
|
|
197
|
+
}
|
|
198
|
+
state.revision += 1;
|
|
199
|
+
return this.writeState(id, state);
|
|
200
|
+
}
|
|
201
|
+
tryReadState(workspaceId) {
|
|
202
|
+
const path = this.statePath(workspaceId);
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = readFileSync(path);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (isErrno(error, "ENOENT"))
|
|
209
|
+
return undefined;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
if (raw.byteLength > MAX_TASK_STATE_BYTES) {
|
|
213
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
throw new Error(`Workspace Task state is not valid JSON: ${errorMessage(error)}`);
|
|
221
|
+
}
|
|
222
|
+
const validated = workspaceTaskStateSchema.safeParse(parsed);
|
|
223
|
+
if (!validated.success) {
|
|
224
|
+
const details = validated.error.issues
|
|
225
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "state"}: ${issue.message}`)
|
|
226
|
+
.join("; ");
|
|
227
|
+
throw new Error(`Workspace Task state has an unsupported or invalid format: ${details}`);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
state: cloneState(validated.data),
|
|
231
|
+
fingerprint: fingerprint(raw),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
writeState(workspaceId, state) {
|
|
235
|
+
const workspaceDir = this.workspaceStateDir(workspaceId);
|
|
236
|
+
mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
|
|
237
|
+
const validated = workspaceTaskStateSchema.parse(state);
|
|
238
|
+
const serialized = `${JSON.stringify(validated, null, 2)}\n`;
|
|
239
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_TASK_STATE_BYTES) {
|
|
240
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
241
|
+
}
|
|
242
|
+
const statePath = this.statePath(workspaceId);
|
|
243
|
+
const tempPath = `${statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
244
|
+
try {
|
|
245
|
+
writeFileSync(tempPath, serialized, { mode: 0o600 });
|
|
246
|
+
renameSync(tempPath, statePath);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
rmSync(tempPath, { force: true });
|
|
250
|
+
}
|
|
251
|
+
return snapshot(validated, fingerprint(Buffer.from(serialized, "utf8")));
|
|
252
|
+
}
|
|
253
|
+
workspaceStateDir(workspaceId) {
|
|
254
|
+
return join(this.stateDir, "workspaces", workspaceId);
|
|
255
|
+
}
|
|
256
|
+
statePath(workspaceId) {
|
|
257
|
+
return join(this.workspaceStateDir(workspaceId), "tasks.json");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function emptyState() {
|
|
261
|
+
return { version: TASK_STATE_VERSION, revision: 0, lists: [] };
|
|
262
|
+
}
|
|
263
|
+
function snapshot(state, stateFingerprint) {
|
|
264
|
+
return {
|
|
265
|
+
...cloneState(state),
|
|
266
|
+
fingerprint: stateFingerprint,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function cloneState(state) {
|
|
270
|
+
return {
|
|
271
|
+
version: state.version,
|
|
272
|
+
revision: state.revision,
|
|
273
|
+
lists: state.lists.map((list) => ({
|
|
274
|
+
...list,
|
|
275
|
+
tasks: list.tasks.map((task) => ({ ...task })),
|
|
276
|
+
})),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function requireList(state, listId) {
|
|
280
|
+
return state.lists[requireListIndex(state, listId)];
|
|
281
|
+
}
|
|
282
|
+
function requireListIndex(state, listId) {
|
|
283
|
+
const index = state.lists.findIndex((list) => list.id === listId);
|
|
284
|
+
if (index < 0)
|
|
285
|
+
throw new Error(`Unknown Task List ${listId}.`);
|
|
286
|
+
return index;
|
|
287
|
+
}
|
|
288
|
+
function requireTaskIndex(list, taskId) {
|
|
289
|
+
const index = list.tasks.findIndex((task) => task.id === taskId);
|
|
290
|
+
if (index < 0)
|
|
291
|
+
throw new Error(`Task List ${list.id} has no Task ${taskId}.`);
|
|
292
|
+
return index;
|
|
293
|
+
}
|
|
294
|
+
function normalizeWorkspaceId(workspaceId) {
|
|
295
|
+
const value = workspaceId.trim();
|
|
296
|
+
if (!/^[a-z][a-z0-9_-]{1,127}$/.test(value)) {
|
|
297
|
+
throw new Error("Workspace ID is not valid for Workspace Task state.");
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function normalizeListName(name) {
|
|
302
|
+
const value = name.trim();
|
|
303
|
+
if (!value)
|
|
304
|
+
throw new Error("Task List name must not be empty.");
|
|
305
|
+
if (value.length > MAX_LIST_NAME_LENGTH) {
|
|
306
|
+
throw new Error(`Task List name must be at most ${MAX_LIST_NAME_LENGTH} characters.`);
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
function normalizeTaskSubject(subject) {
|
|
311
|
+
const value = subject.trim();
|
|
312
|
+
if (!value)
|
|
313
|
+
throw new Error("Task subject must not be empty.");
|
|
314
|
+
if (value.length > MAX_TASK_SUBJECT_LENGTH) {
|
|
315
|
+
throw new Error(`Task subject must be at most ${MAX_TASK_SUBJECT_LENGTH} characters.`);
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
function normalizeTaskContent(content) {
|
|
320
|
+
if (content.length > MAX_TASK_CONTENT_LENGTH) {
|
|
321
|
+
throw new Error(`Task content must be at most ${MAX_TASK_CONTENT_LENGTH} characters.`);
|
|
322
|
+
}
|
|
323
|
+
return content;
|
|
324
|
+
}
|
|
325
|
+
function normalizeInsertPosition(position, length, label) {
|
|
326
|
+
if (position === undefined)
|
|
327
|
+
return length;
|
|
328
|
+
if (!Number.isInteger(position) || position < 0 || position > length) {
|
|
329
|
+
throw new Error(`${label} position must be an integer between 0 and ${length}.`);
|
|
330
|
+
}
|
|
331
|
+
return position;
|
|
332
|
+
}
|
|
333
|
+
function normalizeMovePosition(position, length, label) {
|
|
334
|
+
if (!Number.isInteger(position) || position < 0 || position >= length) {
|
|
335
|
+
throw new Error(`${label} position must be an integer between 0 and ${Math.max(0, length - 1)}.`);
|
|
336
|
+
}
|
|
337
|
+
return position;
|
|
338
|
+
}
|
|
339
|
+
function moveArrayEntry(values, from, to) {
|
|
340
|
+
const [value] = values.splice(from, 1);
|
|
341
|
+
values.splice(to, 0, value);
|
|
342
|
+
}
|
|
343
|
+
function fingerprint(content) {
|
|
344
|
+
return createHash("sha256").update(content).digest("hex");
|
|
345
|
+
}
|
|
346
|
+
function isErrno(error, code) {
|
|
347
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
348
|
+
}
|
|
349
|
+
function errorMessage(error) {
|
|
350
|
+
return error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"release:publish": "node scripts/release/publish.mjs",
|
|
48
48
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
49
49
|
"start": "node dist/cli.js serve",
|
|
50
|
-
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
50
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/workspace-tasks.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
51
51
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
52
|
"release:check": "node scripts/release-version.mjs check",
|
|
53
53
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -265,6 +265,7 @@ try {
|
|
|
265
265
|
"hooks.lifecycle",
|
|
266
266
|
"capability-guides.read",
|
|
267
267
|
"code.intelligence",
|
|
268
|
+
"workspace.tasks",
|
|
268
269
|
"batch.execute",
|
|
269
270
|
...(process.platform === "linux" ? ["artifact.native-download"] : []),
|
|
270
271
|
"ui.mcp-app",
|
|
@@ -276,12 +277,55 @@ try {
|
|
|
276
277
|
"hooks.check",
|
|
277
278
|
"review.changes",
|
|
278
279
|
"code.intelligence",
|
|
280
|
+
"workspace.tasks",
|
|
279
281
|
"batch.execute",
|
|
280
282
|
...(process.platform === "linux" ? ["artifact.download"] : []),
|
|
281
283
|
]);
|
|
282
284
|
assert.equal(capabilityCatalog[0].available, true);
|
|
283
285
|
assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
|
|
284
286
|
|
|
287
|
+
const checkoutTaskStatePath = join(stateDir, "workspaces", workspaceId, "tasks.json");
|
|
288
|
+
assert.ok(existsSync(checkoutTaskStatePath));
|
|
289
|
+
const checkoutTaskList = callTool(oauth.accessToken, sessionId, 130, "capability", {
|
|
290
|
+
workspaceId,
|
|
291
|
+
name: "workspace.tasks",
|
|
292
|
+
action: "run",
|
|
293
|
+
arguments: { operation: "list.create", name: "7677 release tasks" },
|
|
294
|
+
});
|
|
295
|
+
assert.equal(checkoutTaskList.isError, undefined);
|
|
296
|
+
const checkoutListId = checkoutTaskList.structuredContent.result.lists[0].id;
|
|
297
|
+
const checkoutTask = callTool(oauth.accessToken, sessionId, 131, "capability", {
|
|
298
|
+
workspaceId,
|
|
299
|
+
name: "workspace.tasks",
|
|
300
|
+
action: "run",
|
|
301
|
+
arguments: {
|
|
302
|
+
operation: "task.create",
|
|
303
|
+
listId: checkoutListId,
|
|
304
|
+
subject: "Verify v0.8.3 Task persistence",
|
|
305
|
+
content: "created through real MCP",
|
|
306
|
+
status: "in_progress",
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
assert.equal(checkoutTask.isError, undefined);
|
|
310
|
+
const checkoutTaskId = checkoutTask.structuredContent.result.lists[0].tasks[0].id;
|
|
311
|
+
const taskFingerprintBeforeExternal = checkoutTask.structuredContent.result.fingerprint;
|
|
312
|
+
const externalTaskState = JSON.parse(readFileSync(checkoutTaskStatePath, "utf8"));
|
|
313
|
+
externalTaskState.lists[0].tasks[0].content = "reloaded external task edit";
|
|
314
|
+
writeFileSync(checkoutTaskStatePath, `${JSON.stringify(externalTaskState, null, 2)}\n`);
|
|
315
|
+
const reloadedCheckoutTasks = callTool(oauth.accessToken, sessionId, 132, "capability", {
|
|
316
|
+
workspaceId,
|
|
317
|
+
name: "workspace.tasks",
|
|
318
|
+
action: "run",
|
|
319
|
+
arguments: { operation: "get" },
|
|
320
|
+
});
|
|
321
|
+
assert.equal(reloadedCheckoutTasks.isError, undefined);
|
|
322
|
+
assert.equal(reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
|
|
323
|
+
assert.equal(
|
|
324
|
+
reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
|
|
325
|
+
"reloaded external task edit",
|
|
326
|
+
);
|
|
327
|
+
assert.notEqual(reloadedCheckoutTasks.structuredContent.result.fingerprint, taskFingerprintBeforeExternal);
|
|
328
|
+
|
|
285
329
|
const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
|
|
286
330
|
path: checkoutWorkspace,
|
|
287
331
|
newWorkspace: true,
|
|
@@ -312,6 +356,13 @@ try {
|
|
|
312
356
|
assert.equal(closedWorkspace.isError, undefined);
|
|
313
357
|
assert.equal(closedWorkspace.structuredContent.workspaceId, workspaceId);
|
|
314
358
|
assert.equal(closedWorkspace.structuredContent.action, "close");
|
|
359
|
+
const closedCheckoutTasks = callTool(oauth.accessToken, sessionId, 133, "capability", {
|
|
360
|
+
workspaceId,
|
|
361
|
+
name: "workspace.tasks",
|
|
362
|
+
action: "run",
|
|
363
|
+
arguments: { operation: "get" },
|
|
364
|
+
});
|
|
365
|
+
assert.equal(closedCheckoutTasks.isError, true);
|
|
315
366
|
|
|
316
367
|
const closedInventory = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
|
|
317
368
|
action: "list",
|
|
@@ -336,12 +387,34 @@ try {
|
|
|
336
387
|
resumedOriginal.structuredContent.contextFingerprint,
|
|
337
388
|
opened.structuredContent.contextFingerprint,
|
|
338
389
|
);
|
|
390
|
+
const resumedCheckoutTasks = callTool(oauth.accessToken, sessionId, 134, "capability", {
|
|
391
|
+
workspaceId,
|
|
392
|
+
name: "workspace.tasks",
|
|
393
|
+
action: "run",
|
|
394
|
+
arguments: { operation: "get" },
|
|
395
|
+
});
|
|
396
|
+
assert.equal(resumedCheckoutTasks.isError, undefined);
|
|
397
|
+
assert.equal(resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
|
|
398
|
+
assert.equal(
|
|
399
|
+
resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
|
|
400
|
+
"reloaded external task edit",
|
|
401
|
+
);
|
|
402
|
+
assert.equal(resumedOriginal.structuredContent.contextFingerprint, opened.structuredContent.contextFingerprint);
|
|
339
403
|
|
|
340
404
|
const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
|
|
341
405
|
path: lifecycleDeleteWorkspace,
|
|
342
406
|
context: "none",
|
|
343
407
|
}, { "openai/session": "acceptance-workspace-delete" });
|
|
344
408
|
const deleteWorkspaceId = deleteOpened.structuredContent.workspaceId;
|
|
409
|
+
const deleteTaskStatePath = join(stateDir, "workspaces", deleteWorkspaceId, "tasks.json");
|
|
410
|
+
const deleteTaskList = callTool(oauth.accessToken, sessionId, 135, "capability", {
|
|
411
|
+
workspaceId: deleteWorkspaceId,
|
|
412
|
+
name: "workspace.tasks",
|
|
413
|
+
action: "run",
|
|
414
|
+
arguments: { operation: "list.create", name: "delete with workspace" },
|
|
415
|
+
});
|
|
416
|
+
assert.equal(deleteTaskList.isError, undefined);
|
|
417
|
+
assert.ok(existsSync(deleteTaskStatePath));
|
|
345
418
|
const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
|
|
346
419
|
workspaceId: deleteWorkspaceId,
|
|
347
420
|
});
|
|
@@ -353,12 +426,17 @@ try {
|
|
|
353
426
|
assert.equal(deletedWorkspace.isError, undefined);
|
|
354
427
|
assert.equal(deletedWorkspace.structuredContent.workspaceId, deleteWorkspaceId);
|
|
355
428
|
assert.equal(deletedWorkspace.structuredContent.action, "delete");
|
|
429
|
+
assert.equal(existsSync(deleteTaskStatePath), false);
|
|
356
430
|
assert.equal(readFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "utf8"), "keep checkout files\n");
|
|
357
431
|
const deletedInventory = callTool(oauth.accessToken, sessionId, 94, "open_workspace", {
|
|
358
432
|
action: "list",
|
|
359
433
|
workspaceId: deleteWorkspaceId,
|
|
360
434
|
});
|
|
361
435
|
assert.equal(deletedInventory.structuredContent.workspaces.length, 0);
|
|
436
|
+
pass(
|
|
437
|
+
"workspace tasks",
|
|
438
|
+
`${workspaceId} create -> external reload -> close/reopen; explicit delete removed ${deleteWorkspaceId} Task state`,
|
|
439
|
+
);
|
|
362
440
|
|
|
363
441
|
pass(
|
|
364
442
|
"workspace lifecycle + inventory",
|
|
@@ -440,6 +518,7 @@ try {
|
|
|
440
518
|
"host-integration",
|
|
441
519
|
"shell-processes",
|
|
442
520
|
"code-intelligence",
|
|
521
|
+
"workspace-tasks",
|
|
443
522
|
"batch-execution",
|
|
444
523
|
]);
|
|
445
524
|
const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
|
|
@@ -697,6 +776,33 @@ try {
|
|
|
697
776
|
context: "none",
|
|
698
777
|
});
|
|
699
778
|
const compositeWorkspaceId = compositeOpened.structuredContent.workspaceId;
|
|
779
|
+
assert.deepEqual(
|
|
780
|
+
compositeOpened.structuredContent.capabilityCatalog.map((entry) => entry.name),
|
|
781
|
+
["workspace.tasks"],
|
|
782
|
+
);
|
|
783
|
+
const compositeTaskStatePath = join(stateDir, "workspaces", compositeWorkspaceId, "tasks.json");
|
|
784
|
+
assert.ok(existsSync(compositeTaskStatePath));
|
|
785
|
+
const compositeTaskList = callTool(oauth.accessToken, sessionId, 136, "capability", {
|
|
786
|
+
workspaceId: compositeWorkspaceId,
|
|
787
|
+
name: "workspace.tasks",
|
|
788
|
+
action: "run",
|
|
789
|
+
arguments: { operation: "list.create", name: "Composite release tasks" },
|
|
790
|
+
});
|
|
791
|
+
assert.equal(compositeTaskList.isError, undefined);
|
|
792
|
+
const compositeTaskListId = compositeTaskList.structuredContent.result.lists[0].id;
|
|
793
|
+
const compositeTask = callTool(oauth.accessToken, sessionId, 137, "capability", {
|
|
794
|
+
workspaceId: compositeWorkspaceId,
|
|
795
|
+
name: "workspace.tasks",
|
|
796
|
+
action: "run",
|
|
797
|
+
arguments: {
|
|
798
|
+
operation: "task.create",
|
|
799
|
+
listId: compositeTaskListId,
|
|
800
|
+
subject: "Preserve Composite Task state",
|
|
801
|
+
content: "Composite-owned state",
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
assert.equal(compositeTask.isError, undefined);
|
|
805
|
+
const compositeTaskId = compositeTask.structuredContent.result.lists[0].tasks[0].id;
|
|
700
806
|
callTool(oauth.accessToken, sessionId, 117, "open_workspace", {
|
|
701
807
|
action: "member",
|
|
702
808
|
workspaceId: compositeWorkspaceId,
|
|
@@ -707,6 +813,14 @@ try {
|
|
|
707
813
|
workspaceId,
|
|
708
814
|
},
|
|
709
815
|
});
|
|
816
|
+
const memberScopedCompositeTasks = callTool(oauth.accessToken, sessionId, 138, "capability", {
|
|
817
|
+
workspaceId: compositeWorkspaceId,
|
|
818
|
+
member: "code",
|
|
819
|
+
name: "workspace.tasks",
|
|
820
|
+
action: "run",
|
|
821
|
+
arguments: { operation: "get" },
|
|
822
|
+
});
|
|
823
|
+
assert.equal(memberScopedCompositeTasks.isError, true);
|
|
710
824
|
const closedComposite = callTool(oauth.accessToken, sessionId, 118, "close_workspace", {
|
|
711
825
|
workspaceId: compositeWorkspaceId,
|
|
712
826
|
});
|
|
@@ -727,6 +841,13 @@ try {
|
|
|
727
841
|
path: "composite-sentinel.txt",
|
|
728
842
|
});
|
|
729
843
|
assert.equal(closedCompositeRead.isError, true);
|
|
844
|
+
const closedCompositeTasks = callTool(oauth.accessToken, sessionId, 139, "capability", {
|
|
845
|
+
workspaceId: compositeWorkspaceId,
|
|
846
|
+
name: "workspace.tasks",
|
|
847
|
+
action: "run",
|
|
848
|
+
arguments: { operation: "get" },
|
|
849
|
+
});
|
|
850
|
+
assert.equal(closedCompositeTasks.isError, true);
|
|
730
851
|
|
|
731
852
|
const reopenedComposite = callTool(oauth.accessToken, sessionId, 121, "open_workspace", {
|
|
732
853
|
workspaceId: compositeWorkspaceId,
|
|
@@ -735,6 +856,18 @@ try {
|
|
|
735
856
|
assert.equal(reopenedComposite.structuredContent.workspaceId, compositeWorkspaceId);
|
|
736
857
|
assert.equal(reopenedComposite.structuredContent.status, "active");
|
|
737
858
|
assert.equal(reopenedComposite.structuredContent.members[0].workspaceId, workspaceId);
|
|
859
|
+
const reopenedCompositeTasks = callTool(oauth.accessToken, sessionId, 140, "capability", {
|
|
860
|
+
workspaceId: compositeWorkspaceId,
|
|
861
|
+
name: "workspace.tasks",
|
|
862
|
+
action: "run",
|
|
863
|
+
arguments: { operation: "get" },
|
|
864
|
+
});
|
|
865
|
+
assert.equal(reopenedCompositeTasks.isError, undefined);
|
|
866
|
+
assert.equal(reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].id, compositeTaskId);
|
|
867
|
+
assert.equal(
|
|
868
|
+
reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].content,
|
|
869
|
+
"Composite-owned state",
|
|
870
|
+
);
|
|
738
871
|
const reopenedCompositeRead = callTool(oauth.accessToken, sessionId, 122, "read", {
|
|
739
872
|
workspaceId: compositeWorkspaceId,
|
|
740
873
|
member: "code",
|
|
@@ -748,6 +881,7 @@ try {
|
|
|
748
881
|
});
|
|
749
882
|
assert.equal(deletedComposite.structuredContent.action, "delete");
|
|
750
883
|
assert.equal(deletedComposite.structuredContent.dissolved, true);
|
|
884
|
+
assert.equal(existsSync(compositeTaskStatePath), false);
|
|
751
885
|
const memberAfterCompositeDelete = callTool(oauth.accessToken, sessionId, 124, "read", {
|
|
752
886
|
workspaceId,
|
|
753
887
|
path: "composite-sentinel.txt",
|
|
@@ -759,6 +893,10 @@ try {
|
|
|
759
893
|
workspaceId: compositeWorkspaceId,
|
|
760
894
|
});
|
|
761
895
|
assert.equal(deletedCompositeInventory.structuredContent.compositeWorkspaces.length, 0);
|
|
896
|
+
pass(
|
|
897
|
+
"Composite workspace tasks",
|
|
898
|
+
`${compositeWorkspaceId} self-owned Task state -> close/reopen -> delete cleanup; member-scoped Task access rejected`,
|
|
899
|
+
);
|
|
762
900
|
pass(
|
|
763
901
|
"Composite lifecycle",
|
|
764
902
|
`${compositeWorkspaceId} close -> closed/non-routable -> same-id reopen -> delete; member Workspace preserved`,
|