office-core 0.1.0
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/.runtime-dist/scripts/bundle-host-package.js +46 -0
- package/.runtime-dist/scripts/demo-multi-agent.js +130 -0
- package/.runtime-dist/scripts/home-agent-host.js +1403 -0
- package/.runtime-dist/scripts/host-doctor.js +28 -0
- package/.runtime-dist/scripts/host-login.js +32 -0
- package/.runtime-dist/scripts/host-menu.js +227 -0
- package/.runtime-dist/scripts/host-open.js +20 -0
- package/.runtime-dist/scripts/install-host.js +108 -0
- package/.runtime-dist/scripts/lib/host-config.js +171 -0
- package/.runtime-dist/scripts/lib/local-runner.js +287 -0
- package/.runtime-dist/scripts/office-cli.js +698 -0
- package/.runtime-dist/scripts/run-local-project.js +277 -0
- package/.runtime-dist/src/auth/session-token.js +62 -0
- package/.runtime-dist/src/discord/outbox-ledger.js +56 -0
- package/.runtime-dist/src/do/AgentDO.js +205 -0
- package/.runtime-dist/src/do/GatewayShardDO.js +9 -0
- package/.runtime-dist/src/do/ProjectDO.js +829 -0
- package/.runtime-dist/src/do/TaskDO.js +356 -0
- package/.runtime-dist/src/index.js +123 -0
- package/.runtime-dist/src/project/office-view.js +405 -0
- package/.runtime-dist/src/project/read-model.js +79 -0
- package/.runtime-dist/src/routes/agents-bootstrap.js +9 -0
- package/.runtime-dist/src/routes/agents-descriptor.js +12 -0
- package/.runtime-dist/src/routes/agents-events.js +17 -0
- package/.runtime-dist/src/routes/agents-heartbeat.js +21 -0
- package/.runtime-dist/src/routes/agents-task-context.js +17 -0
- package/.runtime-dist/src/routes/bundles.js +198 -0
- package/.runtime-dist/src/routes/local-host.js +49 -0
- package/.runtime-dist/src/routes/projects.js +119 -0
- package/.runtime-dist/src/routes/tasks.js +67 -0
- package/.runtime-dist/src/task/reducer.js +464 -0
- package/.runtime-dist/src/types/project.js +1 -0
- package/.runtime-dist/src/types/protocol.js +3 -0
- package/.runtime-dist/src/types/runtime.js +1 -0
- package/README.md +148 -0
- package/bin/double-penetration-host.mjs +83 -0
- package/package.json +48 -0
- package/public/index.html +1581 -0
- package/public/install-host.ps1 +64 -0
- package/scripts/run-runtime-script.mjs +43 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { parseSessionToken } from "../src/auth/session-token.js";
|
|
4
|
+
import { runLocalRunner } from "./lib/local-runner.js";
|
|
5
|
+
const args = parseArgs(process.argv.slice(2));
|
|
6
|
+
const baseUrl = args.baseUrl ?? "http://127.0.0.1:8787";
|
|
7
|
+
const workdir = path.resolve(args.dir ?? process.cwd());
|
|
8
|
+
const projectId = args.project ?? "prj_demo";
|
|
9
|
+
const taskText = args.task;
|
|
10
|
+
const title = args.title ?? summarizeTitle(taskText);
|
|
11
|
+
const agents = parseAgents(args.agents ?? "codex:agent_alpha,claude:agent_bravo");
|
|
12
|
+
const executorId = args.executor ?? agents[0]?.agent_id ?? "agent_alpha";
|
|
13
|
+
if (!taskText) {
|
|
14
|
+
console.error("Usage: npm run project -- --task \"...\" [--dir PATH] [--agents codex:alpha,claude:bravo]");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
void main().catch((error) => {
|
|
18
|
+
console.error(error);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
});
|
|
21
|
+
async function main() {
|
|
22
|
+
console.log(`Using Office Core at ${baseUrl}`);
|
|
23
|
+
console.log(`Working directory: ${workdir}`);
|
|
24
|
+
const createdTask = await postJson(`${baseUrl}/api/tasks`, {
|
|
25
|
+
project_id: projectId,
|
|
26
|
+
discord_event_id: `discord:${Date.now()}`,
|
|
27
|
+
channel_id: "discord-project-channel",
|
|
28
|
+
title,
|
|
29
|
+
raw_content: taskText,
|
|
30
|
+
requested_by_discord_user_id: "human_demo",
|
|
31
|
+
});
|
|
32
|
+
console.log("Created task:", createdTask.task_id);
|
|
33
|
+
for (const agent of agents) {
|
|
34
|
+
const boot = await postJson(`${baseUrl}/api/agents/bootstrap`, {
|
|
35
|
+
agent_id: agent.agent_id,
|
|
36
|
+
display_name: agent.display_name,
|
|
37
|
+
workspace_id: "ws_demo",
|
|
38
|
+
active_project_id: projectId,
|
|
39
|
+
active_task_id: createdTask.task_id,
|
|
40
|
+
});
|
|
41
|
+
agent.session_token = boot.session_token;
|
|
42
|
+
agent.descriptor = boot.descriptor;
|
|
43
|
+
}
|
|
44
|
+
for (const agent of agents) {
|
|
45
|
+
await updateStatus(agent, createdTask.task_id, "Reading current task", "active");
|
|
46
|
+
const understanding = await generateUnderstanding(agent, createdTask.task_id, taskText);
|
|
47
|
+
await sendCommand(agent, {
|
|
48
|
+
type: "understanding.submit",
|
|
49
|
+
task_id: createdTask.task_id,
|
|
50
|
+
payload: {
|
|
51
|
+
text: understanding,
|
|
52
|
+
confidence: "medium",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const afterUnderstandings = await getTask(createdTask.task_id);
|
|
57
|
+
if (afterUnderstandings.snapshot.phase !== "alignment") {
|
|
58
|
+
throw new Error(`Expected task to reach alignment, got ${afterUnderstandings.snapshot.phase}`);
|
|
59
|
+
}
|
|
60
|
+
const facilitator = agents[0];
|
|
61
|
+
const alignmentText = await generateAlignment(facilitator, createdTask.task_id, afterUnderstandings);
|
|
62
|
+
await sendCommand(facilitator, {
|
|
63
|
+
type: "alignment.proposed",
|
|
64
|
+
task_id: createdTask.task_id,
|
|
65
|
+
payload: {
|
|
66
|
+
proposal_text: alignmentText,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const afterAlignment = await getTask(createdTask.task_id);
|
|
70
|
+
if (afterAlignment.snapshot.phase !== "executing") {
|
|
71
|
+
throw new Error(`Expected task to reach executing, got ${afterAlignment.snapshot.phase}`);
|
|
72
|
+
}
|
|
73
|
+
const executor = agents.find((agent) => agent.agent_id === executorId) ?? agents[0];
|
|
74
|
+
await updateStatus(executor, createdTask.task_id, "Claiming execution", "active");
|
|
75
|
+
const claimResponse = await sendCommand(executor, {
|
|
76
|
+
type: "execution.claim",
|
|
77
|
+
task_id: createdTask.task_id,
|
|
78
|
+
payload: {
|
|
79
|
+
scope: "full",
|
|
80
|
+
approach_summary: "Execute the aligned task in the local workspace.",
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const claimToken = claimResponse.results?.[0]?.claim_token?.claim_token;
|
|
84
|
+
if (!claimToken) {
|
|
85
|
+
throw new Error(`Execution claim failed: ${JSON.stringify(claimResponse)}`);
|
|
86
|
+
}
|
|
87
|
+
const executionSummary = await runExecution(executor, createdTask.task_id, afterAlignment.snapshot.accepted_understanding ?? taskText);
|
|
88
|
+
await sendCommand(executor, {
|
|
89
|
+
type: "progress.update",
|
|
90
|
+
task_id: createdTask.task_id,
|
|
91
|
+
claim_token: claimToken,
|
|
92
|
+
payload: {
|
|
93
|
+
summary: executionSummary.slice(0, 500),
|
|
94
|
+
percent_complete: 100,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await sendCommand(executor, {
|
|
98
|
+
type: "task.complete",
|
|
99
|
+
task_id: createdTask.task_id,
|
|
100
|
+
claim_token: claimToken,
|
|
101
|
+
payload: {
|
|
102
|
+
summary: executionSummary.slice(0, 1000),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
await updateStatus(executor, createdTask.task_id, "Completed task", "done");
|
|
106
|
+
const finalTask = await getTask(createdTask.task_id);
|
|
107
|
+
const finalStatus = await getJson(`${baseUrl}/api/projects/${projectId}/status`);
|
|
108
|
+
console.log("\nFinal task snapshot:");
|
|
109
|
+
console.dir(finalTask.snapshot, { depth: null });
|
|
110
|
+
console.log("\nFinal status feed:");
|
|
111
|
+
console.dir(finalStatus, { depth: null });
|
|
112
|
+
}
|
|
113
|
+
async function generateUnderstanding(agent, taskId, taskText) {
|
|
114
|
+
const descriptor = await refreshDescriptor(agent);
|
|
115
|
+
const manifest = descriptor?.manifest_fetch_url
|
|
116
|
+
? await getJson(`${baseUrl}${descriptor.manifest_fetch_url}`, agent.session_token)
|
|
117
|
+
: null;
|
|
118
|
+
const taskBlob = manifest.blobs?.find((blob) => blob.kind === "task_summary");
|
|
119
|
+
const taskSummary = taskBlob?.fetch_url
|
|
120
|
+
? await getJson(`${baseUrl}${taskBlob.fetch_url}`, agent.session_token)
|
|
121
|
+
: await getTask(taskId);
|
|
122
|
+
const prompt = [
|
|
123
|
+
`You are ${agent.display_name}, one agent in a multi-agent development office.`,
|
|
124
|
+
"Return a short understanding of the current task and your immediate approach.",
|
|
125
|
+
"Keep it to 2-4 sentences, plain text, no markdown bullets.",
|
|
126
|
+
"",
|
|
127
|
+
"Task request:",
|
|
128
|
+
taskText,
|
|
129
|
+
"",
|
|
130
|
+
"Current task snapshot:",
|
|
131
|
+
JSON.stringify(taskSummary, null, 2),
|
|
132
|
+
].join("\n");
|
|
133
|
+
return runLocalRunner({
|
|
134
|
+
runner: agent.runner,
|
|
135
|
+
workdir,
|
|
136
|
+
prompt,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function generateAlignment(agent, taskId, taskState) {
|
|
140
|
+
const prompt = [
|
|
141
|
+
`You are ${agent.display_name}, acting as facilitator.`,
|
|
142
|
+
"Synthesize the team's understanding into one concise execution-ready plan.",
|
|
143
|
+
"Return plain text only, 3-6 sentences.",
|
|
144
|
+
"",
|
|
145
|
+
"Task state:",
|
|
146
|
+
JSON.stringify(taskState, null, 2),
|
|
147
|
+
].join("\n");
|
|
148
|
+
return runLocalRunner({
|
|
149
|
+
runner: agent.runner,
|
|
150
|
+
workdir,
|
|
151
|
+
prompt,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function runExecution(agent, taskId, acceptedUnderstanding) {
|
|
155
|
+
const taskState = await getTask(taskId);
|
|
156
|
+
const prompt = [
|
|
157
|
+
`You are ${agent.display_name}. Execute this task in the local workspace now.`,
|
|
158
|
+
"Make the changes or perform the inspection needed in the working directory.",
|
|
159
|
+
"Return a concise summary of what you changed or found.",
|
|
160
|
+
"",
|
|
161
|
+
"Accepted understanding:",
|
|
162
|
+
acceptedUnderstanding,
|
|
163
|
+
"",
|
|
164
|
+
"Current task state:",
|
|
165
|
+
JSON.stringify(taskState.snapshot, null, 2),
|
|
166
|
+
].join("\n");
|
|
167
|
+
return runLocalRunner({
|
|
168
|
+
runner: agent.runner,
|
|
169
|
+
workdir,
|
|
170
|
+
prompt,
|
|
171
|
+
timeout_ms: 30 * 60 * 1000,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async function updateStatus(agent, taskId, summary, state) {
|
|
175
|
+
await sendCommand(agent, {
|
|
176
|
+
type: "status.upsert",
|
|
177
|
+
task_id: taskId,
|
|
178
|
+
payload: {
|
|
179
|
+
task_summary: summary,
|
|
180
|
+
state,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function sendCommand(agent, command) {
|
|
185
|
+
if (!agent.session_token) {
|
|
186
|
+
throw new Error(`Agent ${agent.agent_id} is not bootstrapped`);
|
|
187
|
+
}
|
|
188
|
+
const claims = parseSessionToken(agent.session_token);
|
|
189
|
+
if (!claims) {
|
|
190
|
+
throw new Error(`Invalid session token for ${agent.agent_id}`);
|
|
191
|
+
}
|
|
192
|
+
const descriptor = await refreshDescriptor(agent);
|
|
193
|
+
const task = await getTask(command.task_id);
|
|
194
|
+
const envelope = {
|
|
195
|
+
protocol_version: "1.0",
|
|
196
|
+
command_schema_version: 1,
|
|
197
|
+
command_id: `${agent.agent_id}:${command.type}:${crypto.randomUUID()}`,
|
|
198
|
+
session_id: claims.session_id,
|
|
199
|
+
session_epoch: claims.session_epoch,
|
|
200
|
+
agent_id: agent.agent_id,
|
|
201
|
+
task_id: command.task_id,
|
|
202
|
+
observed_task_version: task.snapshot.task_version,
|
|
203
|
+
bundle_seq: descriptor?.current_manifest_seq ?? 1,
|
|
204
|
+
claim_token: command.claim_token,
|
|
205
|
+
type: command.type,
|
|
206
|
+
payload: command.payload,
|
|
207
|
+
sent_at: new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
return postJson(`${baseUrl}/api/agents/events`, { commands: [envelope] }, agent.session_token);
|
|
210
|
+
}
|
|
211
|
+
async function refreshDescriptor(agent) {
|
|
212
|
+
if (!agent.session_token) {
|
|
213
|
+
throw new Error(`Agent ${agent.agent_id} is not bootstrapped`);
|
|
214
|
+
}
|
|
215
|
+
agent.descriptor = await getJson(`${baseUrl}/api/agents/descriptor`, agent.session_token);
|
|
216
|
+
return agent.descriptor;
|
|
217
|
+
}
|
|
218
|
+
async function getTask(taskId) {
|
|
219
|
+
return getJson(`${baseUrl}/api/tasks/${taskId}`);
|
|
220
|
+
}
|
|
221
|
+
function parseArgs(argv) {
|
|
222
|
+
const result = {};
|
|
223
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
224
|
+
const item = argv[i];
|
|
225
|
+
if (!item.startsWith("--")) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const key = item.slice(2);
|
|
229
|
+
const value = argv[i + 1];
|
|
230
|
+
if (!value || value.startsWith("--")) {
|
|
231
|
+
result[key] = "true";
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
result[key] = value;
|
|
235
|
+
i += 1;
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
function parseAgents(value) {
|
|
240
|
+
return value.split(",").map((entry, index) => {
|
|
241
|
+
const [runnerRaw, idRaw] = entry.split(":", 2);
|
|
242
|
+
const runner = (runnerRaw?.trim() || "codex");
|
|
243
|
+
const agent_id = idRaw?.trim() || `agent_${index + 1}`;
|
|
244
|
+
return {
|
|
245
|
+
runner,
|
|
246
|
+
agent_id,
|
|
247
|
+
display_name: agent_id,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function summarizeTitle(task) {
|
|
252
|
+
const trimmed = task.trim().replace(/\s+/g, " ");
|
|
253
|
+
return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed;
|
|
254
|
+
}
|
|
255
|
+
async function getJson(url, token) {
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
258
|
+
});
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
throw new Error(`GET ${url} failed: ${response.status} ${await response.text()}`);
|
|
261
|
+
}
|
|
262
|
+
return response.json();
|
|
263
|
+
}
|
|
264
|
+
async function postJson(url, body, token) {
|
|
265
|
+
const response = await fetch(url, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: {
|
|
268
|
+
"content-type": "application/json",
|
|
269
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify(body),
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
throw new Error(`POST ${url} failed: ${response.status} ${await response.text()}`);
|
|
275
|
+
}
|
|
276
|
+
return response.json();
|
|
277
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const SESSION_PREFIX = "devsession";
|
|
2
|
+
const HOST_PREFIX = "hostsession";
|
|
3
|
+
export function mintSessionToken(claims) {
|
|
4
|
+
return `${SESSION_PREFIX}.${base64UrlEncode(JSON.stringify(claims))}`;
|
|
5
|
+
}
|
|
6
|
+
export function parseSessionToken(token) {
|
|
7
|
+
const [prefix, payload] = token.split(".", 2);
|
|
8
|
+
if (prefix !== SESSION_PREFIX || !payload) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(base64UrlDecode(payload));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function readBearerToken(request) {
|
|
19
|
+
const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
|
|
20
|
+
if (!header) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const [scheme, token] = header.split(/\s+/, 2);
|
|
24
|
+
if (!scheme || !token || scheme.toLowerCase() !== "bearer") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return token;
|
|
28
|
+
}
|
|
29
|
+
export function mintHostToken(claims) {
|
|
30
|
+
return `${HOST_PREFIX}.${base64UrlEncode(JSON.stringify(claims))}`;
|
|
31
|
+
}
|
|
32
|
+
export function parseHostToken(token) {
|
|
33
|
+
const [prefix, payload] = token.split(".", 2);
|
|
34
|
+
if (prefix !== HOST_PREFIX || !payload) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(base64UrlDecode(payload));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function requireSessionClaims(request) {
|
|
45
|
+
const token = readBearerToken(request);
|
|
46
|
+
if (!token) {
|
|
47
|
+
return new Response("Missing bearer token", { status: 401 });
|
|
48
|
+
}
|
|
49
|
+
const claims = parseSessionToken(token);
|
|
50
|
+
if (!claims) {
|
|
51
|
+
return new Response("Invalid bearer token", { status: 401 });
|
|
52
|
+
}
|
|
53
|
+
return claims;
|
|
54
|
+
}
|
|
55
|
+
function base64UrlEncode(value) {
|
|
56
|
+
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
57
|
+
}
|
|
58
|
+
function base64UrlDecode(value) {
|
|
59
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
60
|
+
const pad = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
|
|
61
|
+
return atob(normalized + pad);
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function createOutboxLedgerState() {
|
|
2
|
+
return {
|
|
3
|
+
deliveries: {},
|
|
4
|
+
by_idempotency_key: {},
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function recordOutboxDelivery(state, delivery) {
|
|
8
|
+
const existing_id = state.by_idempotency_key[delivery.idempotency_key];
|
|
9
|
+
if (existing_id) {
|
|
10
|
+
return {
|
|
11
|
+
state,
|
|
12
|
+
result: {
|
|
13
|
+
accepted: true,
|
|
14
|
+
duplicate: true,
|
|
15
|
+
delivery_id: existing_id,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
state: {
|
|
21
|
+
deliveries: {
|
|
22
|
+
...state.deliveries,
|
|
23
|
+
[delivery.delivery_id]: {
|
|
24
|
+
...delivery,
|
|
25
|
+
status: "pending",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
by_idempotency_key: {
|
|
29
|
+
...state.by_idempotency_key,
|
|
30
|
+
[delivery.idempotency_key]: delivery.delivery_id,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
result: {
|
|
34
|
+
accepted: true,
|
|
35
|
+
duplicate: false,
|
|
36
|
+
delivery_id: delivery.delivery_id,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function markOutboxDeliverySent(state, input) {
|
|
41
|
+
const existing = state.deliveries[input.delivery_id];
|
|
42
|
+
if (!existing) {
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...state,
|
|
47
|
+
deliveries: {
|
|
48
|
+
...state.deliveries,
|
|
49
|
+
[input.delivery_id]: {
|
|
50
|
+
...existing,
|
|
51
|
+
status: "sent",
|
|
52
|
+
discord_message_id: input.discord_message_id,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mintSessionToken, parseSessionToken, readBearerToken } from "../auth/session-token.js";
|
|
2
|
+
export class AgentDO {
|
|
3
|
+
state;
|
|
4
|
+
env;
|
|
5
|
+
constructor(state, env) {
|
|
6
|
+
this.state = state;
|
|
7
|
+
this.env = env;
|
|
8
|
+
}
|
|
9
|
+
async fetch(request) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
if (request.method === "POST" && url.pathname === "/bootstrap") {
|
|
13
|
+
const body = await request.json();
|
|
14
|
+
const session = await this.bootstrap(body);
|
|
15
|
+
return Response.json(session);
|
|
16
|
+
}
|
|
17
|
+
if (request.method === "GET" && url.pathname === "/descriptor") {
|
|
18
|
+
const session = await this.requireSession(request);
|
|
19
|
+
return Response.json(buildDescriptor(await this.hydrateSession(session)));
|
|
20
|
+
}
|
|
21
|
+
if (request.method === "GET" && url.pathname === "/session") {
|
|
22
|
+
const session = await this.requireSession(request);
|
|
23
|
+
return Response.json(await this.hydrateSession(session));
|
|
24
|
+
}
|
|
25
|
+
if (request.method === "POST" && url.pathname === "/heartbeat") {
|
|
26
|
+
const session = await this.requireSession(request);
|
|
27
|
+
const body = await request.json();
|
|
28
|
+
if (body.session_id !== session.session_id || body.session_epoch !== session.session_epoch) {
|
|
29
|
+
return Response.json({ ok: false, rejection: { code: "stale_session", message: "Session fence mismatch" } }, { status: 409 });
|
|
30
|
+
}
|
|
31
|
+
const updated = {
|
|
32
|
+
...session,
|
|
33
|
+
active_task_id: body.last_task_id ?? session.active_task_id,
|
|
34
|
+
current_manifest_seq: body.last_manifest_seq ?? session.current_manifest_seq,
|
|
35
|
+
last_seen_at: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
await this.saveSession(updated);
|
|
38
|
+
return Response.json({ ok: true, lease_expires_at: plusSeconds(updated.last_seen_at, updated.heartbeat_interval_sec * 2) });
|
|
39
|
+
}
|
|
40
|
+
if (request.method === "POST" && url.pathname === "/events") {
|
|
41
|
+
const session = await this.requireSession(request);
|
|
42
|
+
const body = await request.json();
|
|
43
|
+
const commands = body.commands ?? [];
|
|
44
|
+
const fence = { session_id: session.session_id, session_epoch: session.session_epoch };
|
|
45
|
+
// Group commands by task_id to preserve ordering within a task
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
for (let i = 0; i < commands.length; i++) {
|
|
48
|
+
const cmd = commands[i];
|
|
49
|
+
let group = groups.get(cmd.task_id);
|
|
50
|
+
if (!group) {
|
|
51
|
+
group = { indices: [], commands: [] };
|
|
52
|
+
groups.set(cmd.task_id, group);
|
|
53
|
+
}
|
|
54
|
+
group.indices.push(i);
|
|
55
|
+
group.commands.push(cmd);
|
|
56
|
+
}
|
|
57
|
+
// Process task groups in parallel, commands within each group sequentially
|
|
58
|
+
const results = new Array(commands.length);
|
|
59
|
+
await Promise.all(Array.from(groups.entries()).map(async ([taskId, group]) => {
|
|
60
|
+
const stub = this.env.TASKS.get(this.env.TASKS.idFromName(taskId));
|
|
61
|
+
for (let j = 0; j < group.commands.length; j++) {
|
|
62
|
+
const response = await stub.fetch("https://do.internal/command", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/json" },
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
command: group.commands[j],
|
|
67
|
+
fence,
|
|
68
|
+
claim_expiry_sec: session.claim_expiry_sec,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
results[group.indices[j]] = await response.json();
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
return Response.json({ results });
|
|
75
|
+
}
|
|
76
|
+
if (request.method === "POST" && url.pathname === "/task-context") {
|
|
77
|
+
const session = await this.requireSession(request);
|
|
78
|
+
const body = await request.json();
|
|
79
|
+
const updated = {
|
|
80
|
+
...session,
|
|
81
|
+
active_task_id: body.active_task_id ?? session.active_task_id,
|
|
82
|
+
current_manifest_id: body.current_manifest_id ?? session.current_manifest_id,
|
|
83
|
+
current_manifest_seq: body.current_manifest_seq ?? session.current_manifest_seq,
|
|
84
|
+
task_thread_id: body.task_thread_id ?? session.task_thread_id,
|
|
85
|
+
last_seen_at: new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
await this.saveSession(updated);
|
|
88
|
+
return Response.json({ ok: true, descriptor: buildDescriptor(await this.hydrateSession(updated)) });
|
|
89
|
+
}
|
|
90
|
+
return new Response("Not found", { status: 404 });
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Response) {
|
|
94
|
+
return error;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async bootstrap(input) {
|
|
100
|
+
const previous = await this.loadSession();
|
|
101
|
+
const session_id = `sess_${crypto.randomUUID()}`;
|
|
102
|
+
const session_epoch = (previous?.session_epoch ?? 0) + 1;
|
|
103
|
+
const active_task_id = input.active_task_id ?? previous?.active_task_id ?? "tsk_demo";
|
|
104
|
+
const session = {
|
|
105
|
+
agent_id: input.agent_id,
|
|
106
|
+
display_name: input.display_name ?? previous?.display_name ?? input.agent_id,
|
|
107
|
+
workspace_id: input.workspace_id ?? previous?.workspace_id ?? "ws_demo",
|
|
108
|
+
active_project_id: input.active_project_id ?? previous?.active_project_id ?? "prj_demo",
|
|
109
|
+
active_task_id,
|
|
110
|
+
project_channel_id: previous?.project_channel_id ?? "discord-project-channel",
|
|
111
|
+
task_thread_id: active_task_id ? previous?.task_thread_id ?? `thread_${active_task_id}` : null,
|
|
112
|
+
status_channel_id: previous?.status_channel_id ?? "discord-status-channel",
|
|
113
|
+
session_id,
|
|
114
|
+
session_epoch,
|
|
115
|
+
current_manifest_id: active_task_id ? buildManifestId(active_task_id, previous?.current_manifest_seq ?? 1) : null,
|
|
116
|
+
current_manifest_seq: active_task_id ? 1 : null,
|
|
117
|
+
heartbeat_interval_sec: previous?.heartbeat_interval_sec ?? 15,
|
|
118
|
+
claim_expiry_sec: Number(this.env.CLAIM_EXPIRY_SEC),
|
|
119
|
+
last_seen_at: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
await this.saveSession(session);
|
|
122
|
+
const fence = { agent_id: session.agent_id, session_id, session_epoch };
|
|
123
|
+
return {
|
|
124
|
+
fence,
|
|
125
|
+
session_token: mintSessionToken(fence),
|
|
126
|
+
descriptor: buildDescriptor(await this.hydrateSession(session)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async requireSession(request) {
|
|
130
|
+
const token = readBearerToken(request);
|
|
131
|
+
if (!token) {
|
|
132
|
+
throw new Response("Missing bearer token", { status: 401 });
|
|
133
|
+
}
|
|
134
|
+
const claims = parseSessionToken(token);
|
|
135
|
+
if (!claims) {
|
|
136
|
+
throw new Response("Invalid bearer token", { status: 401 });
|
|
137
|
+
}
|
|
138
|
+
const session = await this.loadSession();
|
|
139
|
+
if (!session) {
|
|
140
|
+
throw new Response("Agent session not found", { status: 401 });
|
|
141
|
+
}
|
|
142
|
+
if (session.agent_id !== claims.agent_id ||
|
|
143
|
+
session.session_id !== claims.session_id ||
|
|
144
|
+
session.session_epoch !== claims.session_epoch) {
|
|
145
|
+
throw new Response("Stale session", { status: 401 });
|
|
146
|
+
}
|
|
147
|
+
return session;
|
|
148
|
+
}
|
|
149
|
+
async loadSession() {
|
|
150
|
+
return (await this.state.storage.get("session")) ?? null;
|
|
151
|
+
}
|
|
152
|
+
async saveSession(session) {
|
|
153
|
+
await this.state.storage.put("session", session);
|
|
154
|
+
}
|
|
155
|
+
async hydrateSession(session) {
|
|
156
|
+
if (!session.active_task_id) {
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
const taskId = this.env.TASKS.idFromName(session.active_task_id);
|
|
160
|
+
const response = await this.env.TASKS.get(taskId).fetch("https://do.internal/snapshot");
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
return session;
|
|
163
|
+
}
|
|
164
|
+
const task = await response.json();
|
|
165
|
+
const snapshot = task?.snapshot ?? task;
|
|
166
|
+
const current_manifest_seq = snapshot?.manifest_seq ?? session.current_manifest_seq;
|
|
167
|
+
return {
|
|
168
|
+
...session,
|
|
169
|
+
current_manifest_seq,
|
|
170
|
+
current_manifest_id: buildManifestId(session.active_task_id, current_manifest_seq),
|
|
171
|
+
task_thread_id: task?.discord_thread_id ?? session.task_thread_id,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function buildDescriptor(session) {
|
|
176
|
+
return {
|
|
177
|
+
descriptor_version: 1,
|
|
178
|
+
protocol_version: "1.0",
|
|
179
|
+
command_schema_version: 1,
|
|
180
|
+
event_schema_version: 1,
|
|
181
|
+
workspace_id: session.workspace_id,
|
|
182
|
+
session_id: session.session_id,
|
|
183
|
+
session_epoch: session.session_epoch,
|
|
184
|
+
active_project_id: session.active_project_id,
|
|
185
|
+
active_task_id: session.active_task_id,
|
|
186
|
+
current_manifest_id: session.current_manifest_id,
|
|
187
|
+
current_manifest_seq: session.current_manifest_seq,
|
|
188
|
+
manifest_fetch_url: session.current_manifest_id ? `/api/bundles/manifests/${session.current_manifest_id}` : null,
|
|
189
|
+
project_channel_id: session.project_channel_id,
|
|
190
|
+
task_thread_id: session.task_thread_id,
|
|
191
|
+
status_channel_id: session.status_channel_id,
|
|
192
|
+
ws_url: `/ws/agent?agent_id=${encodeURIComponent(session.agent_id)}`,
|
|
193
|
+
heartbeat_interval_sec: session.heartbeat_interval_sec,
|
|
194
|
+
claim_expiry_sec: session.claim_expiry_sec,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function plusSeconds(iso, seconds) {
|
|
198
|
+
return new Date(Date.parse(iso) + seconds * 1000).toISOString();
|
|
199
|
+
}
|
|
200
|
+
function buildManifestId(taskId, manifestSeq) {
|
|
201
|
+
if (manifestSeq == null) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return `mf_${taskId}_${manifestSeq}`;
|
|
205
|
+
}
|