@akira-tl/forgerelay 0.6.1 → 0.7.1
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 +43 -0
- package/README.md +38 -0
- package/capabilities/subagents/GUIDE.md +100 -40
- package/dist/activity/lifecycle.js +1 -1
- package/dist/activity/mcp-query-tools.js +1 -0
- package/dist/activity/query-service.js +1 -0
- package/dist/capabilities.js +1 -1
- package/dist/capability-registry.js +34 -0
- package/dist/cli.js +80 -165
- package/dist/composite-activity.js +155 -0
- package/dist/composite-workspaces.js +197 -0
- package/dist/db/migrations.js +14 -0
- package/dist/db/schema.js +6 -0
- package/dist/remote-workspace-relay.js +16 -0
- package/dist/server.js +698 -172
- package/dist/{local-agent-targets.js → subagents/cli-target.js} +6 -6
- package/dist/{local-agent-profiles.js → subagents/profiles.js} +13 -5
- package/dist/subagents/providers/adapters/acp.js +148 -0
- package/dist/subagents/providers/adapters/claude.js +75 -0
- package/dist/{local-agent-runtime.js → subagents/providers/adapters/codex.js} +10 -4
- package/dist/subagents/providers/adapters/opencode.js +137 -0
- package/dist/subagents/providers/adapters/pi.js +232 -0
- package/dist/{local-agent-availability.js → subagents/providers/availability.js} +24 -10
- package/dist/subagents/providers/continuation.js +11 -0
- package/dist/subagents/providers/contract.js +1 -0
- package/dist/subagents/providers/registry.js +26 -0
- package/dist/subagents/providers/shared.js +40 -0
- package/dist/subagents/sessions/capability.js +214 -0
- package/dist/subagents/sessions/delivery-mailbox.js +115 -0
- package/dist/subagents/sessions/execution.js +171 -0
- package/dist/subagents/sessions/manager.js +107 -0
- package/dist/subagents/sessions/mcp/audit.js +85 -0
- package/dist/subagents/sessions/mcp/runtime.js +19 -0
- package/dist/{local-agent-store.js → subagents/sessions/store.js} +75 -39
- 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/dist/workspaces.js +3 -3
- package/docs/chatgpt-coding-workflow.md +2 -9
- package/docs/configuration.md +23 -0
- package/docs/debugging.md +7 -0
- package/docs/roadmap.md +42 -7
- package/package.json +2 -2
- package/scripts/debug/runtime.mjs +23 -1
- package/scripts/debug/runtime.test.mjs +14 -2
- package/scripts/debug/serve.mjs +4 -4
- package/scripts/release/release-gate.test.mjs +2 -2
- package/dist/local-agent-adapters.js +0 -653
- 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/dist/{local-agent-path.js → subagents/providers/path.js} +0 -0
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { readFile, rm } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,13 +11,12 @@ import * as prompts from "@clack/prompts";
|
|
|
11
11
|
import { getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { satisfies } from "semver";
|
|
13
13
|
import { loadConfig } from "./config.js";
|
|
14
|
-
import { formatVisibleHookReports, HookRunner } from "./hooks.js";
|
|
15
14
|
import { runHooksCommand } from "./hook-cli.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
15
|
+
import { executeSubagentSession } from "./subagents/sessions/execution.js";
|
|
16
|
+
import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
|
|
17
|
+
import { SubagentSessionManager } from "./subagents/sessions/manager.js";
|
|
18
|
+
import { formatSubagentProviderAvailabilitySummary } from "./subagents/providers/availability.js";
|
|
19
|
+
import { parseSubagentRunArgs } from "./subagents/cli-target.js";
|
|
21
20
|
import { ensureForgeRelayInstanceId, generateInstanceId, generateOwnerToken, loadDevspaceFiles, removeForgeRelayRemote, renameForgeRelayRemote, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, writeForgeRelayRemote, } from "./user-config.js";
|
|
22
21
|
import { expandHomePath } from "./roots.js";
|
|
23
22
|
import { shutdownHttpServer } from "./server-shutdown.js";
|
|
@@ -193,7 +192,7 @@ async function serve() {
|
|
|
193
192
|
}
|
|
194
193
|
const { createServer } = await import("./server.js");
|
|
195
194
|
const config = loadConfig();
|
|
196
|
-
const { app, close,
|
|
195
|
+
const { app, close, subagentProviders } = createServer(config);
|
|
197
196
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
198
197
|
console.log(`forgerelay listening on http://${config.host}:${config.port}/mcp`);
|
|
199
198
|
console.log(`public base url: ${config.publicBaseUrl}`);
|
|
@@ -205,7 +204,7 @@ async function serve() {
|
|
|
205
204
|
console.log("auth: Owner password approval required");
|
|
206
205
|
console.log(`logging: ${config.logging.level} ${config.logging.format}`);
|
|
207
206
|
if (config.subagents) {
|
|
208
|
-
console.log(`subagent providers: ${
|
|
207
|
+
console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
|
|
209
208
|
}
|
|
210
209
|
});
|
|
211
210
|
let shuttingDown = false;
|
|
@@ -492,92 +491,79 @@ async function runAgentsCommand(args) {
|
|
|
492
491
|
}
|
|
493
492
|
}
|
|
494
493
|
async function runAgentsList() {
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
494
|
+
const manager = createCliSubagentSessionManager();
|
|
495
|
+
try {
|
|
496
|
+
const agents = manager.list(resolveCurrentWorkspaceScope());
|
|
497
|
+
if (agents.length === 0) {
|
|
498
|
+
console.log("No subagent sessions found for this workspace.");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
for (const agent of agents) {
|
|
502
|
+
console.log(formatAgentLine(agent));
|
|
503
|
+
}
|
|
501
504
|
}
|
|
502
|
-
|
|
503
|
-
|
|
505
|
+
finally {
|
|
506
|
+
manager.close();
|
|
504
507
|
}
|
|
505
508
|
}
|
|
506
509
|
async function runAgentsRun(args) {
|
|
507
|
-
const parsed =
|
|
508
|
-
const config = loadConfig();
|
|
510
|
+
const parsed = parseSubagentRunArgs(args);
|
|
509
511
|
const workspaceRoot = resolveCurrentWorkspaceRoot();
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (
|
|
514
|
-
throw new Error(
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
status: "running",
|
|
530
|
-
model: parsed.model ?? existing.model,
|
|
531
|
-
thinking: parsed.thinking ?? existing.thinking,
|
|
532
|
-
}));
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
const profiles = await loadLocalAgentProfiles(config, workspaceRoot);
|
|
536
|
-
const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking);
|
|
537
|
-
if (!target) {
|
|
538
|
-
throw new Error(`Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`);
|
|
512
|
+
const manager = createCliSubagentSessionManager();
|
|
513
|
+
try {
|
|
514
|
+
const existing = manager.get(parsed.target);
|
|
515
|
+
if (existing && (parsed.model || parsed.thinking))
|
|
516
|
+
throw new Error("Existing Subagent Sessions cannot override model or thinking.");
|
|
517
|
+
const started = existing
|
|
518
|
+
? manager.resume({ sessionId: existing.id, prompt: parsed.prompt })
|
|
519
|
+
: await manager.start({
|
|
520
|
+
workspaceId: process.env.FORGERELAY_WORKSPACE_ID ?? process.env.DEVSPACE_WORKSPACE_ID,
|
|
521
|
+
workspaceRoot,
|
|
522
|
+
target: parsed.target,
|
|
523
|
+
prompt: parsed.prompt,
|
|
524
|
+
model: parsed.model,
|
|
525
|
+
thinking: parsed.thinking,
|
|
526
|
+
});
|
|
527
|
+
console.log(formatAgentLine(started.session));
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
manager.close();
|
|
539
531
|
}
|
|
540
|
-
assertLocalAgentProviderAvailable(target.provider);
|
|
541
|
-
const promptFile = writeAgentPromptFile(parsed.prompt);
|
|
542
|
-
const record = store.create({
|
|
543
|
-
workspaceId: process.env.FORGERELAY_WORKSPACE_ID ?? process.env.DEVSPACE_WORKSPACE_ID,
|
|
544
|
-
workspaceRoot,
|
|
545
|
-
profileName: target.name,
|
|
546
|
-
provider: target.provider,
|
|
547
|
-
model: target.model,
|
|
548
|
-
thinking: target.thinking,
|
|
549
|
-
});
|
|
550
|
-
spawnAgentWorker(record.id, promptFile);
|
|
551
|
-
console.log(formatAgentLine({ ...record, status: "running" }));
|
|
552
532
|
}
|
|
553
533
|
async function runAgentsShow(args) {
|
|
554
534
|
const [id] = args;
|
|
555
535
|
if (!id)
|
|
556
536
|
throw new Error("Usage: forgerelay agents show <id>");
|
|
557
537
|
const config = loadConfig();
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
538
|
+
const manager = createCliSubagentSessionManager(config);
|
|
539
|
+
try {
|
|
540
|
+
let record = manager.get(id);
|
|
541
|
+
if (!record)
|
|
542
|
+
throw new Error(`Unknown subagent id: ${id}`);
|
|
543
|
+
const deadline = Date.now() + 15_000;
|
|
544
|
+
while (record.status === "running" && Date.now() < deadline) {
|
|
545
|
+
await sleep(500);
|
|
546
|
+
record = manager.get(id) ?? record;
|
|
547
|
+
}
|
|
548
|
+
console.log(formatAgentLine(record));
|
|
549
|
+
if (record.workspaceId) {
|
|
550
|
+
const deliveries = new SubagentDeliveryMailbox(config.stateDir).claimSession(record.workspaceId, record.id);
|
|
551
|
+
for (const delivery of deliveries) {
|
|
552
|
+
const text = delivery.outcome === "succeeded" ? delivery.finalResponse : delivery.error;
|
|
553
|
+
if (text)
|
|
554
|
+
console.log(text);
|
|
555
|
+
}
|
|
556
|
+
if (deliveries.length > 0)
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (record.latestRun)
|
|
560
|
+
console.log(`Latest run ${record.latestRun.id}: ${record.latestRun.status}`);
|
|
561
|
+
if (record.status === "running") {
|
|
562
|
+
console.log(`No final response yet. Call \`forgerelay agents show ${record.id}\` again later.`);
|
|
563
|
+
}
|
|
578
564
|
}
|
|
579
|
-
|
|
580
|
-
|
|
565
|
+
finally {
|
|
566
|
+
manager.close();
|
|
581
567
|
}
|
|
582
568
|
}
|
|
583
569
|
async function runAgentsWorker(args) {
|
|
@@ -586,96 +572,25 @@ async function runAgentsWorker(args) {
|
|
|
586
572
|
throw new Error("Usage: forgerelay agents __worker <id> --prompt-file <path>");
|
|
587
573
|
}
|
|
588
574
|
const config = loadConfig();
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
agentId: record.id,
|
|
599
|
-
profile: record.profileName,
|
|
600
|
-
provider: record.provider,
|
|
601
|
-
model: record.model,
|
|
602
|
-
thinking: record.thinking,
|
|
575
|
+
const prompt = await readFile(promptFile, "utf8");
|
|
576
|
+
await rm(promptFile, { force: true });
|
|
577
|
+
await executeSubagentSession(config, id, prompt);
|
|
578
|
+
}
|
|
579
|
+
function createCliSubagentSessionManager(config = loadConfig()) {
|
|
580
|
+
return new SubagentSessionManager(config, {
|
|
581
|
+
launch(request) {
|
|
582
|
+
const promptFile = writeSubagentPromptFile(request.prompt);
|
|
583
|
+
spawnSubagentWorker(request.sessionId, promptFile);
|
|
603
584
|
},
|
|
604
|
-
};
|
|
605
|
-
store.update(record.id, { status: "running", error: undefined, hookReports: undefined });
|
|
606
|
-
const hookReports = await hooks.run("SubagentStart", hookInvocation);
|
|
607
|
-
store.update(record.id, { hookReports });
|
|
608
|
-
try {
|
|
609
|
-
const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
|
|
610
|
-
const profile = profiles.find((candidate) => candidate.name === record.profileName);
|
|
611
|
-
const prompt = await readFile(promptFile, "utf8");
|
|
612
|
-
const result = profile
|
|
613
|
-
? await runLocalAgentProfile(profile, record, prompt)
|
|
614
|
-
: await runRawLocalAgentProvider(record, prompt);
|
|
615
|
-
hookReports.push(...await hooks.run("SubagentStop", {
|
|
616
|
-
...hookInvocation,
|
|
617
|
-
payload: {
|
|
618
|
-
...hookInvocation.payload,
|
|
619
|
-
status: "idle",
|
|
620
|
-
providerSessionId: result.providerSessionId,
|
|
621
|
-
},
|
|
622
|
-
}));
|
|
623
|
-
store.update(record.id, {
|
|
624
|
-
providerSessionId: result.providerSessionId ?? undefined,
|
|
625
|
-
status: "idle",
|
|
626
|
-
latestResponse: result.finalResponse,
|
|
627
|
-
error: undefined,
|
|
628
|
-
hookReports,
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
catch (error) {
|
|
632
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
633
|
-
hookReports.push(...await hooks.run("SubagentStop", {
|
|
634
|
-
...hookInvocation,
|
|
635
|
-
payload: {
|
|
636
|
-
...hookInvocation.payload,
|
|
637
|
-
status: "error",
|
|
638
|
-
},
|
|
639
|
-
}));
|
|
640
|
-
store.update(record.id, {
|
|
641
|
-
status: "error",
|
|
642
|
-
error: message,
|
|
643
|
-
hookReports,
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
async function runLocalAgentProfile(profile, record, prompt) {
|
|
648
|
-
const body = profile.body.trim();
|
|
649
|
-
const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt;
|
|
650
|
-
return runLocalAgentProvider(profile.provider, {
|
|
651
|
-
prompt: fullPrompt,
|
|
652
|
-
workspace: record.workspaceRoot,
|
|
653
|
-
providerSessionId: record.providerSessionId,
|
|
654
|
-
writeMode: "allowed",
|
|
655
|
-
model: record.model ?? profile.model,
|
|
656
|
-
thinking: record.thinking ?? profile.thinking,
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
async function runRawLocalAgentProvider(record, prompt) {
|
|
660
|
-
if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) {
|
|
661
|
-
throw new Error(`Subagent profile not found: ${record.profileName}`);
|
|
662
|
-
}
|
|
663
|
-
return runLocalAgentProvider(record.provider, {
|
|
664
|
-
prompt,
|
|
665
|
-
workspace: record.workspaceRoot,
|
|
666
|
-
providerSessionId: record.providerSessionId,
|
|
667
|
-
writeMode: "allowed",
|
|
668
|
-
model: record.model,
|
|
669
|
-
thinking: record.thinking,
|
|
670
585
|
});
|
|
671
586
|
}
|
|
672
|
-
function
|
|
587
|
+
function spawnSubagentWorker(sessionId, promptFile) {
|
|
673
588
|
const child = spawn(process.execPath, [
|
|
674
589
|
...process.execArgv,
|
|
675
590
|
fileURLToPath(import.meta.url),
|
|
676
591
|
"agents",
|
|
677
592
|
"__worker",
|
|
678
|
-
|
|
593
|
+
sessionId,
|
|
679
594
|
"--prompt-file",
|
|
680
595
|
promptFile,
|
|
681
596
|
], {
|
|
@@ -685,7 +600,7 @@ function spawnAgentWorker(agentId, promptFile) {
|
|
|
685
600
|
});
|
|
686
601
|
child.unref();
|
|
687
602
|
}
|
|
688
|
-
function
|
|
603
|
+
function writeSubagentPromptFile(prompt) {
|
|
689
604
|
const directory = mkdtempSync(join(tmpdir(), "forgerelay-agent-prompt-"));
|
|
690
605
|
const filePath = join(directory, "prompt.txt");
|
|
691
606
|
writeFileSync(filePath, prompt, { mode: 0o600 });
|
|
@@ -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
|
+
}
|