@akira-tl/forgerelay 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +38 -0
- package/dist/activity/mcp-query-tools.js +1 -0
- package/dist/activity/query-service.js +1 -0
- package/dist/composite-activity.js +155 -0
- package/dist/composite-workspaces.js +197 -0
- package/dist/remote-workspace-relay.js +16 -0
- package/dist/server.js +673 -144
- package/dist/ui/.vite/manifest.json +33 -33
- package/dist/ui/activity-panel-app.html +3 -3
- package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
- package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
- package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
- package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
- package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
- package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
- package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
- package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
- package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
- package/dist/ui/workspace-app.html +4 -4
- package/dist/ui/workspace-lifecycle-app.html +4 -4
- package/docs/configuration.md +23 -0
- package/docs/debugging.md +7 -0
- package/package.json +1 -1
- package/scripts/debug/runtime.mjs +23 -1
- package/scripts/debug/runtime.test.mjs +14 -2
- package/scripts/debug/serve.mjs +4 -4
- package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
- package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
- package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
- package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.2] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added Composite Workspace as a first-class Workspace kind on the existing `open_workspace` surface. A Composite Workspace has its own persistent `cws_...` identity, may start with zero members, and can mount named local, managed-worktree, or relayed Workspaces with Agent-facing purpose descriptions.
|
|
12
|
+
- Added explicit member routing across filesystem, capability, Bash/process, and Codex-compatible `apply_patch` / `exec_command` / `write_stdin` surfaces. The Composite Workspace ID remains Host-facing while each operation must name its execution member; ForgeRelay never selects or falls back to another member automatically.
|
|
13
|
+
- Added one Composite Host Turn / Activity presentation that aggregates local and remote member activity with member labels while keeping audit, process, filesystem, and remote execution facts owned by the actual member Workspace/Execution ForgeRelay.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `close_workspace` now dissolves a Composite Workspace without closing member Workspaces, finalizing managed worktrees, interrupting member processes, deleting files, or removing Workspace Relay routes.
|
|
18
|
+
- Interactive debug launchers now derive their displayed local endpoints from the selected `FORGERELAY_DEBUG_CONFIG_DIR/config.json`, allowing multiple isolated debug instances to use separate config/state/worktree directories and different ports such as 7677 and 6768.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Routed Codex-compatible process and patch tools through the same Workspace Relay and Composite member resolution path as the canonical tool surface, including explicit no-fallback behavior for remote members.
|
|
23
|
+
|
|
7
24
|
## [0.6.1] - 2026-08-28
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -101,6 +101,44 @@ Once a workspace is open, the host can:
|
|
|
101
101
|
Normal work happens in your existing checkout. ForgeRelay does not silently move
|
|
102
102
|
every task into a worktree.
|
|
103
103
|
|
|
104
|
+
## Composite workspaces across multiple devices
|
|
105
|
+
|
|
106
|
+
When one task needs more than one execution environment, a Composite Workspace keeps
|
|
107
|
+
one Host-facing working context while preserving each member Workspace as a separate
|
|
108
|
+
execution boundary. For example, `code` can point at a local checkout while
|
|
109
|
+
`compute` points through Workspace Relay at a GPU machine.
|
|
110
|
+
|
|
111
|
+
Composite Workspaces use the same lifecycle entry points as ordinary Workspaces:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
open_workspace({ kind: "composite", name: "research-project" })
|
|
115
|
+
open_workspace({
|
|
116
|
+
action: "member",
|
|
117
|
+
workspaceId: "cws_...",
|
|
118
|
+
memberAction: "add",
|
|
119
|
+
member: {
|
|
120
|
+
name: "compute",
|
|
121
|
+
purpose: "GPU and high-performance computation",
|
|
122
|
+
path: "/srv/research",
|
|
123
|
+
relay: "gpu-server"
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Work still names the member explicitly:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
read({ workspaceId: "cws_...", member: "code", path: "src/model.py" })
|
|
132
|
+
bash({ workspaceId: "cws_...", member: "compute", command: "python train.py" })
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
ForgeRelay does not merge member filesystems, Git state, Hooks, Skills, processes,
|
|
136
|
+
or audit facts, and it never infers a member from the tool type or purpose text.
|
|
137
|
+
The Composite Activity Panel presents member operations in one Host Turn while the
|
|
138
|
+
actual facts remain owned by the member Workspace. `close_workspace` on a Composite
|
|
139
|
+
Workspace means **dissolve**: the Composite identity and membership are removed, but
|
|
140
|
+
member Workspaces, worktrees, running jobs, files, and relay routes are preserved.
|
|
141
|
+
|
|
104
142
|
### Progressive MCP context
|
|
105
143
|
|
|
106
144
|
ForgeRelay keeps the callable MCP surface and its low-frequency operating manuals
|
|
@@ -20,6 +20,7 @@ const activitySummarySchema = z.object({
|
|
|
20
20
|
target: z.string(),
|
|
21
21
|
detailAvailable: z.boolean(),
|
|
22
22
|
workspaceId: z.string().optional(),
|
|
23
|
+
member: z.string().optional(),
|
|
23
24
|
processId: z.number().int().positive().optional(),
|
|
24
25
|
outputId: z.string().optional(),
|
|
25
26
|
commandLength: z.number().int().nonnegative().optional(),
|
|
@@ -126,6 +126,7 @@ function toSummary(record) {
|
|
|
126
126
|
target: activityTarget(record, request, result, structured),
|
|
127
127
|
detailAvailable: !bulkGroup && record.tool !== "rename" && record.tool !== "delete" && record.tool !== "batch",
|
|
128
128
|
...(record.workspace.id ? { workspaceId: record.workspace.id } : {}),
|
|
129
|
+
...(stringField(request, "member") ? { member: stringField(request, "member") } : {}),
|
|
129
130
|
...(processId !== undefined ? { processId } : {}),
|
|
130
131
|
...(outputId !== undefined ? { outputId } : {}),
|
|
131
132
|
...(bashLike && command !== undefined ? { commandLength: command.length } : {}),
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export class CompositeActivityCoordinator {
|
|
2
|
+
composites;
|
|
3
|
+
queries;
|
|
4
|
+
remoteWorkspaces;
|
|
5
|
+
turns = new Map();
|
|
6
|
+
constructor(composites, queries, remoteWorkspaces) {
|
|
7
|
+
this.composites = composites;
|
|
8
|
+
this.queries = queries;
|
|
9
|
+
this.remoteWorkspaces = remoteWorkspaces;
|
|
10
|
+
}
|
|
11
|
+
hasComposite(workspaceId) {
|
|
12
|
+
return this.composites.has(workspaceId);
|
|
13
|
+
}
|
|
14
|
+
beginPanel(workspaceId, conversationScopeId) {
|
|
15
|
+
this.composites.open(workspaceId);
|
|
16
|
+
const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
|
|
17
|
+
this.turns.set(snapshot.turnId, {
|
|
18
|
+
compositeWorkspaceId: workspaceId,
|
|
19
|
+
conversationScopeId,
|
|
20
|
+
remoteMembers: new Map(),
|
|
21
|
+
remoteActivities: new Map(),
|
|
22
|
+
remoteOutputs: new Map(),
|
|
23
|
+
});
|
|
24
|
+
return snapshotResult(snapshot, `Started Composite Workspace Host Turn ${snapshot.turnId}.`);
|
|
25
|
+
}
|
|
26
|
+
currentTurnId(conversationScopeId, compositeWorkspaceId) {
|
|
27
|
+
const turnId = this.queries.currentTurnId(conversationScopeId, compositeWorkspaceId);
|
|
28
|
+
return turnId && this.turns.has(turnId) ? turnId : undefined;
|
|
29
|
+
}
|
|
30
|
+
async prepareMember(compositeWorkspaceId, member, executionWorkspaceId, conversationScopeId) {
|
|
31
|
+
const turnId = this.currentTurnId(conversationScopeId, compositeWorkspaceId);
|
|
32
|
+
if (!turnId)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (!this.remoteWorkspaces.has(executionWorkspaceId))
|
|
35
|
+
return turnId;
|
|
36
|
+
const state = this.turns.get(turnId);
|
|
37
|
+
const existing = state.remoteMembers.get(member);
|
|
38
|
+
if (existing?.workspaceId === executionWorkspaceId)
|
|
39
|
+
return turnId;
|
|
40
|
+
const panel = await this.remoteWorkspaces.activityPanel(executionWorkspaceId, conversationScopeId);
|
|
41
|
+
const remoteTurnId = requiredString(panel.structuredContent, "turnId", "Remote Activity panel");
|
|
42
|
+
state.remoteMembers.set(member, {
|
|
43
|
+
member,
|
|
44
|
+
workspaceId: executionWorkspaceId,
|
|
45
|
+
remoteTurnId,
|
|
46
|
+
});
|
|
47
|
+
return turnId;
|
|
48
|
+
}
|
|
49
|
+
async snapshot(turnId, knownRevision) {
|
|
50
|
+
const state = this.turns.get(turnId);
|
|
51
|
+
if (!state)
|
|
52
|
+
return undefined;
|
|
53
|
+
const local = this.queries.snapshot(turnId);
|
|
54
|
+
const remoteSnapshots = await Promise.all([...state.remoteMembers.values()].map(async (route) => ({
|
|
55
|
+
route,
|
|
56
|
+
snapshot: await this.remoteWorkspaces.activitySnapshot({ turnId: route.remoteTurnId }, state.conversationScopeId),
|
|
57
|
+
})));
|
|
58
|
+
state.remoteActivities.clear();
|
|
59
|
+
state.remoteOutputs.clear();
|
|
60
|
+
const remoteActivities = [];
|
|
61
|
+
let revision = local.revision;
|
|
62
|
+
for (const { route, snapshot } of remoteSnapshots) {
|
|
63
|
+
if (!snapshot?.structuredContent)
|
|
64
|
+
continue;
|
|
65
|
+
const structured = snapshot.structuredContent;
|
|
66
|
+
const remoteRevision = structured.revision;
|
|
67
|
+
if (typeof remoteRevision === "number" && Number.isInteger(remoteRevision) && remoteRevision >= 0) {
|
|
68
|
+
revision += remoteRevision;
|
|
69
|
+
}
|
|
70
|
+
const activities = Array.isArray(structured.activities)
|
|
71
|
+
? structured.activities
|
|
72
|
+
: [];
|
|
73
|
+
for (const activity of activities) {
|
|
74
|
+
const presented = { ...activity, member: route.member };
|
|
75
|
+
remoteActivities.push(presented);
|
|
76
|
+
state.remoteActivities.set(activity.activityId, route);
|
|
77
|
+
if (activity.outputId)
|
|
78
|
+
state.remoteOutputs.set(activity.outputId, route);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const activities = [...local.activities, ...remoteActivities]
|
|
82
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
83
|
+
const changed = knownRevision === undefined || knownRevision !== revision;
|
|
84
|
+
const snapshot = {
|
|
85
|
+
turnId,
|
|
86
|
+
revision,
|
|
87
|
+
changed,
|
|
88
|
+
state: aggregateState(activities),
|
|
89
|
+
activities: changed ? activities : [],
|
|
90
|
+
};
|
|
91
|
+
return snapshotResult(snapshot, changed
|
|
92
|
+
? `Composite Activity snapshot ${turnId} revision ${revision}.`
|
|
93
|
+
: `Composite Activity snapshot ${turnId} unchanged at revision ${revision}.`);
|
|
94
|
+
}
|
|
95
|
+
async detail(turnId, activityId) {
|
|
96
|
+
const state = this.turns.get(turnId);
|
|
97
|
+
if (!state)
|
|
98
|
+
return undefined;
|
|
99
|
+
const route = state.remoteActivities.get(activityId);
|
|
100
|
+
if (!route)
|
|
101
|
+
return undefined;
|
|
102
|
+
const result = await this.remoteWorkspaces.activityDetail(route.remoteTurnId, activityId, state.conversationScopeId);
|
|
103
|
+
if (!result?.structuredContent)
|
|
104
|
+
return result;
|
|
105
|
+
const structured = result.structuredContent;
|
|
106
|
+
const activity = structured.activity;
|
|
107
|
+
return {
|
|
108
|
+
...result,
|
|
109
|
+
structuredContent: {
|
|
110
|
+
...structured,
|
|
111
|
+
...(activity && typeof activity === "object"
|
|
112
|
+
? { activity: { ...activity, member: route.member } }
|
|
113
|
+
: {}),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async output(turnId, outputId) {
|
|
118
|
+
const state = this.turns.get(turnId);
|
|
119
|
+
if (!state)
|
|
120
|
+
return undefined;
|
|
121
|
+
const route = state.remoteOutputs.get(outputId);
|
|
122
|
+
if (!route)
|
|
123
|
+
return undefined;
|
|
124
|
+
return this.remoteWorkspaces.activityOutput(route.remoteTurnId, outputId, state.conversationScopeId);
|
|
125
|
+
}
|
|
126
|
+
forgetComposite(workspaceId) {
|
|
127
|
+
for (const [turnId, state] of this.turns) {
|
|
128
|
+
if (state.compositeWorkspaceId === workspaceId)
|
|
129
|
+
this.turns.delete(turnId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function snapshotResult(snapshot, text) {
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: "text", text }],
|
|
136
|
+
structuredContent: snapshot,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function aggregateState(activities) {
|
|
140
|
+
if (activities.length === 0)
|
|
141
|
+
return "working";
|
|
142
|
+
if (activities.some((activity) => activity.status === "working"))
|
|
143
|
+
return "working";
|
|
144
|
+
if (activities.some((activity) => activity.status === "error"))
|
|
145
|
+
return "error";
|
|
146
|
+
return "done";
|
|
147
|
+
}
|
|
148
|
+
function requiredString(value, field, label) {
|
|
149
|
+
if (!value || typeof value !== "object")
|
|
150
|
+
throw new Error(`${label} did not return structured content.`);
|
|
151
|
+
const fieldValue = value[field];
|
|
152
|
+
if (typeof fieldValue !== "string" || !fieldValue)
|
|
153
|
+
throw new Error(`${label} did not include ${field}.`);
|
|
154
|
+
return fieldValue;
|
|
155
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export class CompositeWorkspaceRegistry {
|
|
5
|
+
stateDir;
|
|
6
|
+
statePath;
|
|
7
|
+
records = new Map();
|
|
8
|
+
constructor(stateDir) {
|
|
9
|
+
this.stateDir = stateDir;
|
|
10
|
+
this.statePath = join(stateDir, "composite-workspaces.json");
|
|
11
|
+
this.load();
|
|
12
|
+
}
|
|
13
|
+
has(workspaceId) {
|
|
14
|
+
return this.records.has(workspaceId);
|
|
15
|
+
}
|
|
16
|
+
create(name) {
|
|
17
|
+
const normalized = normalizeName(name);
|
|
18
|
+
const existing = [...this.records.values()].find((record) => record.name === normalized);
|
|
19
|
+
if (existing)
|
|
20
|
+
return this.touch(existing.id);
|
|
21
|
+
const now = new Date().toISOString();
|
|
22
|
+
const record = {
|
|
23
|
+
id: `cws_${randomBytes(5).toString("hex")}`,
|
|
24
|
+
kind: "composite",
|
|
25
|
+
name: normalized,
|
|
26
|
+
members: [],
|
|
27
|
+
createdAt: now,
|
|
28
|
+
lastUsedAt: now,
|
|
29
|
+
};
|
|
30
|
+
this.records.set(record.id, record);
|
|
31
|
+
this.persist();
|
|
32
|
+
return cloneRecord(record);
|
|
33
|
+
}
|
|
34
|
+
get(workspaceId) {
|
|
35
|
+
const record = this.records.get(workspaceId);
|
|
36
|
+
if (!record)
|
|
37
|
+
throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
|
|
38
|
+
return cloneRecord(record);
|
|
39
|
+
}
|
|
40
|
+
open(workspaceId) {
|
|
41
|
+
return this.touch(workspaceId);
|
|
42
|
+
}
|
|
43
|
+
list() {
|
|
44
|
+
return [...this.records.values()]
|
|
45
|
+
.map(cloneRecord)
|
|
46
|
+
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
47
|
+
}
|
|
48
|
+
addMember(workspaceId, input) {
|
|
49
|
+
const record = this.requireRecord(workspaceId);
|
|
50
|
+
const name = normalizeMemberName(input.name);
|
|
51
|
+
const purpose = input.purpose.trim();
|
|
52
|
+
if (!purpose)
|
|
53
|
+
throw new Error("Composite Workspace member purpose must not be empty.");
|
|
54
|
+
const existing = record.members.find((member) => member.name === name);
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.purpose === purpose && existing.workspaceId === input.workspaceId) {
|
|
57
|
+
return cloneRecord(record);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Composite Workspace ${workspaceId} already has member ${name} with a different definition.`);
|
|
60
|
+
}
|
|
61
|
+
record.members.push({ name, purpose, workspaceId: input.workspaceId });
|
|
62
|
+
record.lastUsedAt = new Date().toISOString();
|
|
63
|
+
this.persist();
|
|
64
|
+
return cloneRecord(record);
|
|
65
|
+
}
|
|
66
|
+
updateMember(workspaceId, memberName, input) {
|
|
67
|
+
const record = this.requireRecord(workspaceId);
|
|
68
|
+
const currentName = normalizeMemberName(memberName);
|
|
69
|
+
const index = record.members.findIndex((member) => member.name === currentName);
|
|
70
|
+
if (index < 0)
|
|
71
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${currentName}.`);
|
|
72
|
+
const current = record.members[index];
|
|
73
|
+
const nextName = input.name === undefined ? current.name : normalizeMemberName(input.name);
|
|
74
|
+
const nextPurpose = input.purpose === undefined ? current.purpose : input.purpose.trim();
|
|
75
|
+
if (!nextPurpose)
|
|
76
|
+
throw new Error("Composite Workspace member purpose must not be empty.");
|
|
77
|
+
const nextWorkspaceId = input.workspaceId ?? current.workspaceId;
|
|
78
|
+
if (nextName !== current.name &&
|
|
79
|
+
record.members.some((member, memberIndex) => memberIndex !== index && member.name === nextName)) {
|
|
80
|
+
throw new Error(`Composite Workspace ${workspaceId} already has member ${nextName}.`);
|
|
81
|
+
}
|
|
82
|
+
if (nextName === current.name &&
|
|
83
|
+
nextPurpose === current.purpose &&
|
|
84
|
+
nextWorkspaceId === current.workspaceId) {
|
|
85
|
+
return cloneRecord(record);
|
|
86
|
+
}
|
|
87
|
+
record.members[index] = {
|
|
88
|
+
name: nextName,
|
|
89
|
+
purpose: nextPurpose,
|
|
90
|
+
workspaceId: nextWorkspaceId,
|
|
91
|
+
};
|
|
92
|
+
record.lastUsedAt = new Date().toISOString();
|
|
93
|
+
this.persist();
|
|
94
|
+
return cloneRecord(record);
|
|
95
|
+
}
|
|
96
|
+
removeMember(workspaceId, memberName) {
|
|
97
|
+
const record = this.requireRecord(workspaceId);
|
|
98
|
+
const name = normalizeMemberName(memberName);
|
|
99
|
+
const index = record.members.findIndex((member) => member.name === name);
|
|
100
|
+
if (index < 0)
|
|
101
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
|
|
102
|
+
record.members.splice(index, 1);
|
|
103
|
+
record.lastUsedAt = new Date().toISOString();
|
|
104
|
+
this.persist();
|
|
105
|
+
return cloneRecord(record);
|
|
106
|
+
}
|
|
107
|
+
member(workspaceId, memberName) {
|
|
108
|
+
const record = this.requireRecord(workspaceId);
|
|
109
|
+
const name = normalizeMemberName(memberName);
|
|
110
|
+
const member = record.members.find((entry) => entry.name === name);
|
|
111
|
+
if (!member)
|
|
112
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
|
|
113
|
+
return { ...member };
|
|
114
|
+
}
|
|
115
|
+
dissolve(workspaceId) {
|
|
116
|
+
const record = this.requireRecord(workspaceId);
|
|
117
|
+
this.records.delete(workspaceId);
|
|
118
|
+
this.persist();
|
|
119
|
+
return cloneRecord(record);
|
|
120
|
+
}
|
|
121
|
+
touch(workspaceId) {
|
|
122
|
+
const record = this.requireRecord(workspaceId);
|
|
123
|
+
record.lastUsedAt = new Date().toISOString();
|
|
124
|
+
this.persist();
|
|
125
|
+
return cloneRecord(record);
|
|
126
|
+
}
|
|
127
|
+
requireRecord(workspaceId) {
|
|
128
|
+
const record = this.records.get(workspaceId);
|
|
129
|
+
if (!record)
|
|
130
|
+
throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
|
|
131
|
+
return record;
|
|
132
|
+
}
|
|
133
|
+
load() {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isMissingFile(error))
|
|
140
|
+
return;
|
|
141
|
+
throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
|
|
142
|
+
}
|
|
143
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
|
|
144
|
+
throw new Error("Composite Workspace state has an unsupported format.");
|
|
145
|
+
}
|
|
146
|
+
for (const record of parsed.workspaces) {
|
|
147
|
+
if (!record?.id?.startsWith("cws_") || record.kind !== "composite")
|
|
148
|
+
continue;
|
|
149
|
+
this.records.set(record.id, {
|
|
150
|
+
...record,
|
|
151
|
+
members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
persist() {
|
|
156
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
157
|
+
const state = {
|
|
158
|
+
version: 1,
|
|
159
|
+
workspaces: [...this.records.values()].map(cloneRecord),
|
|
160
|
+
};
|
|
161
|
+
const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
164
|
+
renameSync(tempPath, this.statePath);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
rmSync(tempPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function normalizeName(name) {
|
|
172
|
+
const value = name.trim();
|
|
173
|
+
if (!value)
|
|
174
|
+
throw new Error("Composite Workspace name must not be empty.");
|
|
175
|
+
if (value.length > 120)
|
|
176
|
+
throw new Error("Composite Workspace name must be at most 120 characters.");
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function normalizeMemberName(name) {
|
|
180
|
+
const value = name.trim();
|
|
181
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(value)) {
|
|
182
|
+
throw new Error("Composite Workspace member name must match /^[a-z][a-z0-9_-]{0,31}$/.");
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function cloneRecord(record) {
|
|
187
|
+
return {
|
|
188
|
+
...record,
|
|
189
|
+
members: record.members.map((member) => ({ ...member })),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function isMissingFile(error) {
|
|
193
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
194
|
+
}
|
|
195
|
+
function errorMessage(error) {
|
|
196
|
+
return error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
@@ -101,6 +101,13 @@ export class RemoteWorkspaceRelay {
|
|
|
101
101
|
instruction: `${remoteInstruction}\nThis workspace executes on remote ${alias}.`,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
async resumeWorkspace(gatewayWorkspaceId, context = "auto", conversationScopeId) {
|
|
105
|
+
const result = await this.callWorkspaceTool(gatewayWorkspaceId, "open_workspace", { context }, conversationScopeId);
|
|
106
|
+
if (result.isError === true) {
|
|
107
|
+
throw new Error(`Remote open_workspace failed: ${toolResultText(result)}`);
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
104
111
|
async read(gatewayWorkspaceId, input, conversationScopeId) {
|
|
105
112
|
return this.callWorkspaceTool(gatewayWorkspaceId, "read", input, conversationScopeId);
|
|
106
113
|
}
|
|
@@ -119,6 +126,15 @@ export class RemoteWorkspaceRelay {
|
|
|
119
126
|
async bash(gatewayWorkspaceId, input, conversationScopeId) {
|
|
120
127
|
return this.callWorkspaceTool(gatewayWorkspaceId, "bash", input, conversationScopeId);
|
|
121
128
|
}
|
|
129
|
+
async execCommand(gatewayWorkspaceId, input, conversationScopeId) {
|
|
130
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "exec_command", input, conversationScopeId);
|
|
131
|
+
}
|
|
132
|
+
async writeStdin(gatewayWorkspaceId, input, conversationScopeId) {
|
|
133
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "write_stdin", input, conversationScopeId);
|
|
134
|
+
}
|
|
135
|
+
async applyPatch(gatewayWorkspaceId, input, conversationScopeId) {
|
|
136
|
+
return this.callWorkspaceTool(gatewayWorkspaceId, "apply_patch", input, conversationScopeId);
|
|
137
|
+
}
|
|
122
138
|
async capability(gatewayWorkspaceId, input, conversationScopeId) {
|
|
123
139
|
return this.callWorkspaceTool(gatewayWorkspaceId, "capability", input, conversationScopeId);
|
|
124
140
|
}
|