@timurproko/a1 0.1.1-dev.1 → 0.1.1-dev.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/README.md +13 -0
- package/bin/a1-ui.js +2 -2
- package/dist/src/composition/index.d.ts +18 -0
- package/dist/src/composition/index.js +36 -0
- package/dist/src/composition/structured-workspace-application.d.ts +38 -0
- package/dist/src/composition/structured-workspace-application.js +261 -0
- package/dist/src/features/workspace/index.d.ts +1 -0
- package/dist/src/features/workspace/index.js +1 -0
- package/dist/src/features/workspace/router.d.ts +5 -0
- package/dist/src/features/workspace/router.js +26 -0
- package/dist/src/features/workspace/structured-tabs.d.ts +83 -0
- package/dist/src/features/workspace/structured-tabs.js +453 -0
- package/dist/src/foundation/release/bootstrap.js +37 -5
- package/dist/src/foundation/release/concurrency.d.ts +1 -0
- package/dist/src/foundation/release/concurrency.js +17 -0
- package/dist/src/foundation/release/release-store.d.ts +20 -1
- package/dist/src/foundation/release/release-store.js +43 -9
- package/dist/src/foundation/release/release.js +7 -5
- package/dist/src/foundation/release/update.d.ts +1 -1
- package/dist/src/foundation/release/update.js +5 -3
- package/dist/src/foundation/supervision/main.js +6 -5
- package/package.json +2 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { AGENT_ENGINE_CONTRACT_VERSION, assertAgentCapabilityContract, assertAgentEvent, assertAgentSnapshot, } from "../../foundation/agent-engine-contracts/index.js";
|
|
2
|
+
import { WORKSPACE_CONTRACT_VERSION, } from "../../foundation/workspace-contracts/index.js";
|
|
3
|
+
import { presentWorkspace } from "./presentation.js";
|
|
4
|
+
import { WorkspaceReducer } from "./reducer.js";
|
|
5
|
+
import { WorkspaceRouter } from "./router.js";
|
|
6
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
7
|
+
maxAgents: 8,
|
|
8
|
+
maxMessagesPerAgent: 512,
|
|
9
|
+
maxMessageBytes: 256 * 1024,
|
|
10
|
+
maxEditorBytes: 64 * 1024,
|
|
11
|
+
});
|
|
12
|
+
export class StructuredWorkspaceTabs {
|
|
13
|
+
router;
|
|
14
|
+
#cwd;
|
|
15
|
+
#createEngine;
|
|
16
|
+
#limits;
|
|
17
|
+
#now;
|
|
18
|
+
#tabs = new Map();
|
|
19
|
+
#listeners = new Set();
|
|
20
|
+
#disposed = false;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
if (!options.cwd || options.cwd.includes("\0"))
|
|
23
|
+
throw new TypeError("structured workspace cwd is invalid");
|
|
24
|
+
if (typeof options.createEngine !== "function")
|
|
25
|
+
throw new TypeError("structured workspace engine factory is required");
|
|
26
|
+
this.#cwd = options.cwd;
|
|
27
|
+
this.#createEngine = options.createEngine;
|
|
28
|
+
this.#limits = validateLimits({ ...DEFAULT_LIMITS, ...options.limits });
|
|
29
|
+
this.#now = options.now ?? (() => new Date().toISOString());
|
|
30
|
+
this.router = new WorkspaceRouter(new WorkspaceReducer(options.workspaceId ?? "workspace-default"));
|
|
31
|
+
}
|
|
32
|
+
subscribe(listener) {
|
|
33
|
+
this.#listeners.add(listener);
|
|
34
|
+
listener(this.view());
|
|
35
|
+
return () => this.#listeners.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
async createAgent(input) {
|
|
38
|
+
if (this.#disposed)
|
|
39
|
+
return rejected("workspace-disposed", "structured workspace is disposed");
|
|
40
|
+
if (this.#tabs.size >= this.#limits.maxAgents)
|
|
41
|
+
return rejected("agent-limit", `structured workspace is limited to ${this.#limits.maxAgents} agents`);
|
|
42
|
+
if (this.#tabs.has(input.id))
|
|
43
|
+
return rejected("duplicate-agent", `structured workspace agent already exists: ${input.id}`);
|
|
44
|
+
const sessionId = input.sessionId ?? `${input.id}.session`;
|
|
45
|
+
let runtime;
|
|
46
|
+
try {
|
|
47
|
+
runtime = await this.#createRuntime(input.id, sessionId);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return rejected("engine-start-failed", diagnostic(error));
|
|
51
|
+
}
|
|
52
|
+
const descriptor = {
|
|
53
|
+
id: input.id,
|
|
54
|
+
displayName: input.displayName,
|
|
55
|
+
adapterId: `engine.${input.id}`,
|
|
56
|
+
runtime: "structured",
|
|
57
|
+
lifecycle: workspaceLifecycle(runtime.snapshot.lifecycle),
|
|
58
|
+
capability: workspaceCapability(runtime.engine, input.id, this.#limits),
|
|
59
|
+
createdAt: this.#now(),
|
|
60
|
+
recoveryReferenceId: null,
|
|
61
|
+
};
|
|
62
|
+
const created = await this.router.createAgent(descriptor);
|
|
63
|
+
if (created.kind === "rejected") {
|
|
64
|
+
await disposeRuntime(runtime.session, runtime.engine);
|
|
65
|
+
return created;
|
|
66
|
+
}
|
|
67
|
+
const tab = {
|
|
68
|
+
agentId: input.id,
|
|
69
|
+
sessionId,
|
|
70
|
+
engine: runtime.engine,
|
|
71
|
+
session: runtime.session,
|
|
72
|
+
unsubscribe: () => { },
|
|
73
|
+
eventTail: Promise.resolve(),
|
|
74
|
+
lifecycle: runtime.snapshot.lifecycle,
|
|
75
|
+
transcript: this.#boundedSnapshot(runtime.snapshot),
|
|
76
|
+
editorText: "",
|
|
77
|
+
activeCommandIds: new Set(runtime.snapshot.activeCommandIds),
|
|
78
|
+
lastSequence: runtime.snapshot.sequence,
|
|
79
|
+
failure: null,
|
|
80
|
+
};
|
|
81
|
+
tab.unsubscribe = tab.session.subscribe(event => this.#queueEvent(tab, event));
|
|
82
|
+
this.#tabs.set(input.id, tab);
|
|
83
|
+
await this.#reflectLifecycle(tab, runtime.snapshot.lifecycle, null);
|
|
84
|
+
this.#notify();
|
|
85
|
+
return applied(this.view(), this.#tabView(tab));
|
|
86
|
+
}
|
|
87
|
+
async selectAgent(agentId) {
|
|
88
|
+
const selected = await this.router.selectAgent(agentId);
|
|
89
|
+
if (selected.kind === "rejected")
|
|
90
|
+
return selected;
|
|
91
|
+
const tab = this.#tabs.get(agentId);
|
|
92
|
+
if (!tab)
|
|
93
|
+
return rejected("missing-runtime", `structured runtime is missing for ${agentId}`);
|
|
94
|
+
this.#notify();
|
|
95
|
+
return applied(this.view(), this.#tabView(tab));
|
|
96
|
+
}
|
|
97
|
+
setEditorText(agentId, text) {
|
|
98
|
+
const tab = this.#tabs.get(agentId);
|
|
99
|
+
if (!tab)
|
|
100
|
+
return rejected("unknown-agent", `structured workspace agent does not exist: ${agentId}`);
|
|
101
|
+
if (typeof text !== "string" || byteLength(text) > this.#limits.maxEditorBytes) {
|
|
102
|
+
return rejected("editor-limit", `structured editor exceeds ${this.#limits.maxEditorBytes} bytes`);
|
|
103
|
+
}
|
|
104
|
+
tab.editorText = text;
|
|
105
|
+
this.#notify();
|
|
106
|
+
return applied(this.view(), this.#tabView(tab));
|
|
107
|
+
}
|
|
108
|
+
async submitSelected() {
|
|
109
|
+
const selectedAgentId = this.router.view().selectedAgentId;
|
|
110
|
+
if (!selectedAgentId)
|
|
111
|
+
return rejected("no-selected-agent", "no structured workspace agent is selected");
|
|
112
|
+
const tab = this.#tabs.get(selectedAgentId);
|
|
113
|
+
if (!tab)
|
|
114
|
+
return rejected("missing-runtime", `structured runtime is missing for ${selectedAgentId}`);
|
|
115
|
+
return await this.sendPrompt(selectedAgentId, tab.editorText);
|
|
116
|
+
}
|
|
117
|
+
async sendPrompt(agentId, text) {
|
|
118
|
+
if (typeof text !== "string" || text.length === 0)
|
|
119
|
+
return rejected("empty-prompt", "structured prompt must not be empty");
|
|
120
|
+
if (byteLength(text) > this.#limits.maxEditorBytes)
|
|
121
|
+
return rejected("editor-limit", `structured prompt exceeds ${this.#limits.maxEditorBytes} bytes`);
|
|
122
|
+
const routed = await this.router.sendStructuredCommand(agentId, "prompt", { text });
|
|
123
|
+
if (routed.kind === "rejected")
|
|
124
|
+
return routed;
|
|
125
|
+
const tab = this.#tabs.get(agentId);
|
|
126
|
+
if (!tab)
|
|
127
|
+
return rejected("missing-runtime", `structured runtime is missing for ${agentId}`);
|
|
128
|
+
const correlationId = routed.value.correlationId;
|
|
129
|
+
tab.activeCommandIds.add(correlationId);
|
|
130
|
+
if (tab.editorText === text)
|
|
131
|
+
tab.editorText = "";
|
|
132
|
+
let outcome;
|
|
133
|
+
try {
|
|
134
|
+
outcome = await tab.session.execute({
|
|
135
|
+
contractVersion: AGENT_ENGINE_CONTRACT_VERSION,
|
|
136
|
+
type: "prompt",
|
|
137
|
+
commandId: correlationId,
|
|
138
|
+
sessionId: tab.sessionId,
|
|
139
|
+
text,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
outcome = "failed";
|
|
144
|
+
await this.#failTab(tab, "command-failed", diagnostic(error));
|
|
145
|
+
}
|
|
146
|
+
if (outcome !== "accepted") {
|
|
147
|
+
tab.activeCommandIds.delete(correlationId);
|
|
148
|
+
await this.router.settleStructuredCommand(agentId, correlationId, outcome === "completed" ? "completed" : "failed");
|
|
149
|
+
}
|
|
150
|
+
this.#notify();
|
|
151
|
+
return applied(this.view(), { correlationId, outcome });
|
|
152
|
+
}
|
|
153
|
+
async stopAgent(agentId) {
|
|
154
|
+
const tab = this.#tabs.get(agentId);
|
|
155
|
+
if (!tab)
|
|
156
|
+
return rejected("unknown-agent", `structured workspace agent does not exist: ${agentId}`);
|
|
157
|
+
tab.unsubscribe();
|
|
158
|
+
try {
|
|
159
|
+
await disposeRuntime(tab.session, tab.engine);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
await this.#failTab(tab, "stop-failed", diagnostic(error));
|
|
163
|
+
return rejected("stop-failed", diagnostic(error));
|
|
164
|
+
}
|
|
165
|
+
tab.lifecycle = "stopped";
|
|
166
|
+
tab.activeCommandIds.clear();
|
|
167
|
+
const stopped = await this.router.stopAgent(agentId);
|
|
168
|
+
if (stopped.kind === "rejected")
|
|
169
|
+
return stopped;
|
|
170
|
+
this.#notify();
|
|
171
|
+
return applied(this.view(), this.#tabView(tab));
|
|
172
|
+
}
|
|
173
|
+
async restartAgent(agentId) {
|
|
174
|
+
const tab = this.#tabs.get(agentId);
|
|
175
|
+
if (!tab)
|
|
176
|
+
return rejected("unknown-agent", `structured workspace agent does not exist: ${agentId}`);
|
|
177
|
+
await this.router.restartAgent(agentId);
|
|
178
|
+
tab.unsubscribe();
|
|
179
|
+
await disposeRuntime(tab.session, tab.engine).catch(() => undefined);
|
|
180
|
+
try {
|
|
181
|
+
const runtime = await this.#createRuntime(agentId, tab.sessionId);
|
|
182
|
+
tab.engine = runtime.engine;
|
|
183
|
+
tab.session = runtime.session;
|
|
184
|
+
tab.lifecycle = runtime.snapshot.lifecycle;
|
|
185
|
+
tab.transcript = this.#boundedSnapshot(runtime.snapshot);
|
|
186
|
+
tab.activeCommandIds = new Set(runtime.snapshot.activeCommandIds);
|
|
187
|
+
tab.lastSequence = runtime.snapshot.sequence;
|
|
188
|
+
tab.failure = null;
|
|
189
|
+
tab.eventTail = Promise.resolve();
|
|
190
|
+
tab.unsubscribe = tab.session.subscribe(event => this.#queueEvent(tab, event));
|
|
191
|
+
await this.#reflectLifecycle(tab, runtime.snapshot.lifecycle, null);
|
|
192
|
+
this.#notify();
|
|
193
|
+
return applied(this.view(), this.#tabView(tab));
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await this.#failTab(tab, "restart-failed", diagnostic(error));
|
|
197
|
+
return rejected("restart-failed", diagnostic(error));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async refreshAgent(agentId) {
|
|
201
|
+
const tab = this.#tabs.get(agentId);
|
|
202
|
+
if (!tab)
|
|
203
|
+
return rejected("unknown-agent", `structured workspace agent does not exist: ${agentId}`);
|
|
204
|
+
try {
|
|
205
|
+
const snapshot = await tab.session.snapshot();
|
|
206
|
+
assertAgentSnapshot(snapshot);
|
|
207
|
+
if (snapshot.sessionId !== tab.sessionId)
|
|
208
|
+
throw new TypeError("structured snapshot session identity changed");
|
|
209
|
+
tab.lifecycle = snapshot.lifecycle;
|
|
210
|
+
tab.transcript = this.#boundedSnapshot(snapshot);
|
|
211
|
+
tab.activeCommandIds = new Set(snapshot.activeCommandIds);
|
|
212
|
+
tab.lastSequence = snapshot.sequence;
|
|
213
|
+
tab.failure = null;
|
|
214
|
+
await this.#reflectLifecycle(tab, snapshot.lifecycle, null);
|
|
215
|
+
this.#notify();
|
|
216
|
+
return applied(this.view(), this.#tabView(tab));
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
await this.#failTab(tab, "snapshot-failed", diagnostic(error));
|
|
220
|
+
return rejected("snapshot-failed", diagnostic(error));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async removeAgent(agentId) {
|
|
224
|
+
const tab = this.#tabs.get(agentId);
|
|
225
|
+
if (!tab)
|
|
226
|
+
return rejected("unknown-agent", `structured workspace agent does not exist: ${agentId}`);
|
|
227
|
+
const removed = await this.router.removeAgent(agentId);
|
|
228
|
+
if (removed.kind === "rejected")
|
|
229
|
+
return removed;
|
|
230
|
+
tab.unsubscribe();
|
|
231
|
+
await disposeRuntime(tab.session, tab.engine).catch(() => undefined);
|
|
232
|
+
this.#tabs.delete(agentId);
|
|
233
|
+
this.#notify();
|
|
234
|
+
return applied(this.view(), agentId);
|
|
235
|
+
}
|
|
236
|
+
async flush() {
|
|
237
|
+
await Promise.all([...this.#tabs.values()].map(tab => tab.eventTail));
|
|
238
|
+
}
|
|
239
|
+
view() {
|
|
240
|
+
const workspace = this.router.view();
|
|
241
|
+
const panels = workspace.agents.flatMap(agent => {
|
|
242
|
+
const tab = this.#tabs.get(agent.id);
|
|
243
|
+
return tab ? [this.#tabView(tab)] : [];
|
|
244
|
+
});
|
|
245
|
+
const presentation = presentWorkspace(workspace);
|
|
246
|
+
const rows = new Map(presentation.rows.map(row => [row.agentId, row]));
|
|
247
|
+
const tabs = panels.map(panel => {
|
|
248
|
+
const row = rows.get(panel.agentId);
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
role: "tab",
|
|
251
|
+
agentId: panel.agentId,
|
|
252
|
+
label: row?.label ?? panel.agentId,
|
|
253
|
+
selected: panel.selected,
|
|
254
|
+
accessibleDescription: row?.accessibleDescription ?? panel.accessibleDescription,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
role: "tablist",
|
|
259
|
+
workspace,
|
|
260
|
+
presentation,
|
|
261
|
+
tabs: Object.freeze(tabs),
|
|
262
|
+
panels: Object.freeze(panels),
|
|
263
|
+
selectedPanel: panels.find(panel => panel.selected) ?? null,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async dispose() {
|
|
267
|
+
if (this.#disposed)
|
|
268
|
+
return;
|
|
269
|
+
this.#disposed = true;
|
|
270
|
+
const failures = [];
|
|
271
|
+
for (const tab of [...this.#tabs.values()].reverse()) {
|
|
272
|
+
tab.unsubscribe();
|
|
273
|
+
await tab.eventTail.catch(error => failures.push(error));
|
|
274
|
+
await disposeRuntime(tab.session, tab.engine).catch(error => failures.push(error));
|
|
275
|
+
}
|
|
276
|
+
this.#tabs.clear();
|
|
277
|
+
this.#listeners.clear();
|
|
278
|
+
if (failures.length > 0)
|
|
279
|
+
throw new AggregateError(failures, "structured workspace disposal failed");
|
|
280
|
+
}
|
|
281
|
+
async #createRuntime(agentId, sessionId) {
|
|
282
|
+
const engine = await this.#createEngine(agentId);
|
|
283
|
+
try {
|
|
284
|
+
assertAgentCapabilityContract(engine.capabilities);
|
|
285
|
+
const session = await engine.createSession({ sessionId, cwd: this.#cwd });
|
|
286
|
+
assertAgentCapabilityContract(session.capabilities);
|
|
287
|
+
const snapshot = await session.snapshot();
|
|
288
|
+
assertAgentSnapshot(snapshot);
|
|
289
|
+
if (session.sessionId !== sessionId || snapshot.sessionId !== sessionId)
|
|
290
|
+
throw new TypeError("structured session identity does not match its workspace tab");
|
|
291
|
+
return { engine, session, snapshot };
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
await engine.dispose().catch(() => undefined);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
#queueEvent(tab, event) {
|
|
299
|
+
tab.eventTail = tab.eventTail
|
|
300
|
+
.then(() => this.#applyEvent(tab, event))
|
|
301
|
+
.catch(error => this.#failTab(tab, "event-failed", diagnostic(error)))
|
|
302
|
+
.then(() => this.#notify());
|
|
303
|
+
}
|
|
304
|
+
async #applyEvent(tab, event) {
|
|
305
|
+
assertAgentEvent(event, tab.session.capabilities);
|
|
306
|
+
if (event.sessionId !== tab.sessionId)
|
|
307
|
+
throw new TypeError("structured event crossed workspace tab identity");
|
|
308
|
+
if (event.sequence <= tab.lastSequence)
|
|
309
|
+
return;
|
|
310
|
+
if (event.sequence !== tab.lastSequence + 1 || event.type === "snapshot-invalidated") {
|
|
311
|
+
await this.refreshAgent(tab.agentId);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
tab.lastSequence = event.sequence;
|
|
315
|
+
if (event.type === "content") {
|
|
316
|
+
this.#appendMessage(tab, event.content);
|
|
317
|
+
await this.router.recordActivity(tab.agentId);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (event.type === "lifecycle") {
|
|
321
|
+
tab.lifecycle = event.lifecycle;
|
|
322
|
+
await this.#reflectLifecycle(tab, event.lifecycle, event.reason);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (event.type === "command-outcome") {
|
|
326
|
+
tab.activeCommandIds.delete(event.commandId);
|
|
327
|
+
await this.router.settleStructuredCommand(tab.agentId, event.commandId, event.outcome === "completed" ? "completed" : "failed");
|
|
328
|
+
if (event.outcome === "failed")
|
|
329
|
+
await this.router.requestAttention(tab.agentId);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (event.type === "diagnostic") {
|
|
333
|
+
if (event.recoverable)
|
|
334
|
+
await this.router.requestAttention(tab.agentId);
|
|
335
|
+
else
|
|
336
|
+
await this.#failTab(tab, event.code, event.message);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
#appendMessage(tab, message) {
|
|
340
|
+
if (byteLength(JSON.stringify(message)) > this.#limits.maxMessageBytes)
|
|
341
|
+
throw new RangeError("structured message exceeds its workspace tab byte limit");
|
|
342
|
+
tab.transcript.push(message);
|
|
343
|
+
while (tab.transcript.length > this.#limits.maxMessagesPerAgent)
|
|
344
|
+
tab.transcript.shift();
|
|
345
|
+
}
|
|
346
|
+
#boundedSnapshot(snapshot) {
|
|
347
|
+
if (byteLength(JSON.stringify(snapshot)) > snapshot.capabilities.snapshots.maxBytes)
|
|
348
|
+
throw new RangeError("structured snapshot exceeds its negotiated byte limit");
|
|
349
|
+
const messages = snapshot.content.slice(-this.#limits.maxMessagesPerAgent);
|
|
350
|
+
for (const message of messages) {
|
|
351
|
+
if (byteLength(JSON.stringify(message)) > this.#limits.maxMessageBytes)
|
|
352
|
+
throw new RangeError("structured snapshot message exceeds its workspace tab byte limit");
|
|
353
|
+
}
|
|
354
|
+
return [...messages];
|
|
355
|
+
}
|
|
356
|
+
async #reflectLifecycle(tab, lifecycle, reason) {
|
|
357
|
+
if (lifecycle === "ready" || lifecycle === "busy")
|
|
358
|
+
await this.router.markRecovered(tab.agentId);
|
|
359
|
+
else if (lifecycle === "starting")
|
|
360
|
+
await this.router.restartAgent(tab.agentId);
|
|
361
|
+
else if (lifecycle === "stopped")
|
|
362
|
+
await this.router.stopAgent(tab.agentId);
|
|
363
|
+
else if (lifecycle === "failed")
|
|
364
|
+
await this.#failTab(tab, "engine-failed", reason ?? "structured engine failed");
|
|
365
|
+
}
|
|
366
|
+
async #failTab(tab, code, message) {
|
|
367
|
+
tab.lifecycle = "failed";
|
|
368
|
+
tab.failure = `${code}: ${message}`;
|
|
369
|
+
tab.activeCommandIds.clear();
|
|
370
|
+
await this.router.markFailed(tab.agentId, code, message);
|
|
371
|
+
}
|
|
372
|
+
#notify() {
|
|
373
|
+
const view = this.view();
|
|
374
|
+
for (const listener of this.#listeners)
|
|
375
|
+
listener(view);
|
|
376
|
+
}
|
|
377
|
+
#tabView(tab) {
|
|
378
|
+
const selected = this.router.view().selectedAgentId === tab.agentId;
|
|
379
|
+
const toolMessages = tab.transcript.filter(message => message.role === "tool" || message.content.some(content => content.kind === "tool-call" || content.kind === "tool-result"));
|
|
380
|
+
return Object.freeze({
|
|
381
|
+
role: "tabpanel",
|
|
382
|
+
agentId: tab.agentId,
|
|
383
|
+
sessionId: tab.sessionId,
|
|
384
|
+
selected,
|
|
385
|
+
lifecycle: tab.lifecycle,
|
|
386
|
+
transcript: Object.freeze([...tab.transcript]),
|
|
387
|
+
toolMessages: Object.freeze(toolMessages),
|
|
388
|
+
editorText: tab.editorText,
|
|
389
|
+
activeCommandIds: Object.freeze([...tab.activeCommandIds]),
|
|
390
|
+
lastSequence: tab.lastSequence,
|
|
391
|
+
failure: tab.failure,
|
|
392
|
+
accessibleDescription: `${selected ? "selected" : "background"} structured agent ${tab.agentId}; ${tab.lifecycle}; ${tab.transcript.length} messages`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function workspaceCapability(engine, agentId, limits) {
|
|
397
|
+
return {
|
|
398
|
+
kind: "structured",
|
|
399
|
+
protocolVersion: WORKSPACE_CONTRACT_VERSION,
|
|
400
|
+
adapterId: `engine.${agentId}`,
|
|
401
|
+
commands: Object.freeze([...engine.capabilities.commands]),
|
|
402
|
+
eventTypes: Object.freeze([...engine.capabilities.events]),
|
|
403
|
+
snapshots: engine.capabilities.snapshots.supported ? "authoritative" : "none",
|
|
404
|
+
resume: engine.capabilities.snapshots.supported ? "snapshot" : "none",
|
|
405
|
+
cancellation: engine.capabilities.commands.includes("abort") ? "correlated" : "none",
|
|
406
|
+
attachmentTypes: Object.freeze([]),
|
|
407
|
+
flow: Object.freeze({
|
|
408
|
+
maxEventBytes: limits.maxMessageBytes,
|
|
409
|
+
maxSnapshotBytes: engine.capabilities.snapshots.maxBytes,
|
|
410
|
+
maxAttachmentBytes: limits.maxMessageBytes,
|
|
411
|
+
maxQueuedEvents: limits.maxMessagesPerAgent,
|
|
412
|
+
maxConcurrentCommands: 4,
|
|
413
|
+
maxReconnectEvents: limits.maxMessagesPerAgent,
|
|
414
|
+
}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function workspaceLifecycle(lifecycle) {
|
|
418
|
+
if (lifecycle === "starting")
|
|
419
|
+
return "creating";
|
|
420
|
+
if (lifecycle === "stopping")
|
|
421
|
+
return "stopping";
|
|
422
|
+
if (lifecycle === "stopped")
|
|
423
|
+
return "stopped";
|
|
424
|
+
if (lifecycle === "failed")
|
|
425
|
+
return "failed";
|
|
426
|
+
return "ready";
|
|
427
|
+
}
|
|
428
|
+
function validateLimits(limits) {
|
|
429
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
430
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
431
|
+
throw new RangeError(`structured workspace ${name} must be a positive safe integer`);
|
|
432
|
+
}
|
|
433
|
+
return Object.freeze(limits);
|
|
434
|
+
}
|
|
435
|
+
async function disposeRuntime(session, engine) {
|
|
436
|
+
const failures = [];
|
|
437
|
+
await session.dispose().catch(error => failures.push(error));
|
|
438
|
+
await engine.dispose().catch(error => failures.push(error));
|
|
439
|
+
if (failures.length > 0)
|
|
440
|
+
throw new AggregateError(failures, "structured agent runtime disposal failed");
|
|
441
|
+
}
|
|
442
|
+
function applied(view, value) {
|
|
443
|
+
return { kind: "applied", view, value };
|
|
444
|
+
}
|
|
445
|
+
function rejected(code, message) {
|
|
446
|
+
return { kind: "rejected", code, diagnostic: message };
|
|
447
|
+
}
|
|
448
|
+
function diagnostic(error) {
|
|
449
|
+
return error instanceof Error ? error.message : String(error);
|
|
450
|
+
}
|
|
451
|
+
function byteLength(value) {
|
|
452
|
+
return new TextEncoder().encode(value).byteLength;
|
|
453
|
+
}
|
|
@@ -8,7 +8,7 @@ import { CohortStateStore } from "./cohort-state.js";
|
|
|
8
8
|
import { assertLaunchProfileId, resolveProductPaths } from "../lifecycle/index.js";
|
|
9
9
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
10
10
|
import { cleanupProvenIdleOwner, processIsAlive } from "./process-cleanup.js";
|
|
11
|
-
import { materializeRelease, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
11
|
+
import { materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
12
12
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
13
13
|
export async function runBootstrap(options) {
|
|
14
14
|
const environment = { ...(options.environment ?? process.env) };
|
|
@@ -18,18 +18,43 @@ export async function runBootstrap(options) {
|
|
|
18
18
|
const output = options.output ?? process.stderr;
|
|
19
19
|
const paths = resolveProductPaths(environment);
|
|
20
20
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
21
|
-
const candidate = await materializeRelease(options.packageRoot, paths.dataDir);
|
|
22
21
|
const stateStore = new CohortStateStore(paths.dataDir);
|
|
23
|
-
await stateStore.recordCandidate(candidate);
|
|
24
22
|
let state = await stateStore.read();
|
|
23
|
+
let endpoint = await readEndpointMetadata(paths.endpointMetadataPath);
|
|
24
|
+
let probe = endpoint ? await probeOwnership(endpoint) : "dead";
|
|
25
|
+
const installedVersion = await readInstalledVersion(options.packageRoot);
|
|
26
|
+
const activeId = state.references.active;
|
|
27
|
+
const active = activeId === null ? undefined : state.releases[activeId];
|
|
28
|
+
if (active?.approval === "approved" && active.packageVersion === installedVersion) {
|
|
29
|
+
const endpointMatches = endpoint?.releaseId === active.releaseId
|
|
30
|
+
&& endpoint.releaseRoot === active.releaseRoot
|
|
31
|
+
&& endpoint.contentDigest === active.contentDigest;
|
|
32
|
+
if (endpointMatches && probe === "live-verified") {
|
|
33
|
+
const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
|
|
34
|
+
return await launchUi(retained, environment);
|
|
35
|
+
}
|
|
36
|
+
if (endpoint === null || probe === "dead") {
|
|
37
|
+
if (endpoint)
|
|
38
|
+
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
|
|
39
|
+
const retained = await readMaterializedRelease(active.releaseRoot);
|
|
40
|
+
await startSupervisor(retained, environment);
|
|
41
|
+
await waitForVerifiedEndpoint(paths.endpointMetadataPath, retained, 8_000);
|
|
42
|
+
return await launchUi(retained, environment);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const candidate = await materializeRelease(options.packageRoot, paths.dataDir, {
|
|
46
|
+
onProgress: progress => output.write(`${PRODUCT_TEXT.diagnostic(`installing ${progress.fileCount} files.`)}\n`),
|
|
47
|
+
});
|
|
48
|
+
await stateStore.recordCandidate(candidate);
|
|
49
|
+
state = await stateStore.read();
|
|
25
50
|
if (!state.references.active) {
|
|
26
51
|
const diagnosticsPath = await certifyMaterializedRelease(candidate, paths.dataDir);
|
|
27
52
|
await stateStore.approve(candidate.releaseId, diagnosticsPath);
|
|
28
53
|
await stateStore.activate(candidate.releaseId);
|
|
29
54
|
state = await stateStore.read();
|
|
30
55
|
}
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
endpoint = await readEndpointMetadata(paths.endpointMetadataPath);
|
|
57
|
+
probe = endpoint ? await probeOwnership(endpoint) : "dead";
|
|
33
58
|
let decision = selectCohortLaunch(candidate, state, endpoint, probe);
|
|
34
59
|
if (decision.action === "blocked") {
|
|
35
60
|
await stateStore.blockPending(decision.reason, endpoint?.ownership.liveGenerationIds ?? []);
|
|
@@ -93,6 +118,13 @@ export async function runBootstrap(options) {
|
|
|
93
118
|
await waitForVerifiedEndpoint(paths.endpointMetadataPath, selected, 8_000);
|
|
94
119
|
return await launchUi(selected, environment);
|
|
95
120
|
}
|
|
121
|
+
async function readInstalledVersion(packageRoot) {
|
|
122
|
+
const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
|
123
|
+
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
|
124
|
+
throw new Error(PRODUCT_TEXT.diagnostic("package metadata has no version"));
|
|
125
|
+
}
|
|
126
|
+
return manifest.version;
|
|
127
|
+
}
|
|
96
128
|
export async function certifyMaterializedRelease(release, dataDir) {
|
|
97
129
|
await verifyMaterializedRelease(release.releaseRoot, release, resolve(dataDir, "releases"));
|
|
98
130
|
const path = resolve(dataDir, `certification-${release.releaseId}.json`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function mapWithConcurrency<Value, Result>(values: readonly Value[], concurrency: number, operation: (value: Value, index: number) => Promise<Result>): Promise<Result[]>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export async function mapWithConcurrency(values, concurrency, operation) {
|
|
2
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1)
|
|
3
|
+
throw new RangeError("concurrency must be a positive safe integer");
|
|
4
|
+
const results = new Array(values.length);
|
|
5
|
+
let nextIndex = 0;
|
|
6
|
+
const worker = async () => {
|
|
7
|
+
while (true) {
|
|
8
|
+
const index = nextIndex;
|
|
9
|
+
nextIndex += 1;
|
|
10
|
+
if (index >= values.length)
|
|
11
|
+
return;
|
|
12
|
+
results[index] = await operation(values[index], index);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
@@ -3,8 +3,27 @@ export declare const RELEASE_MANIFEST_FILENAME: string;
|
|
|
3
3
|
export interface MaterializedRelease extends ReleaseIdentity {
|
|
4
4
|
readonly releaseRoot: string;
|
|
5
5
|
}
|
|
6
|
-
export
|
|
6
|
+
export interface MaterializeReleaseOptions {
|
|
7
|
+
readonly onProgress?: (progress: {
|
|
8
|
+
readonly phase: "copying";
|
|
9
|
+
readonly fileCount: number;
|
|
10
|
+
}) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface CertifiedReleaseRecord {
|
|
13
|
+
readonly releaseId: string;
|
|
14
|
+
readonly releaseRoot: string;
|
|
15
|
+
readonly packageVersion?: string;
|
|
16
|
+
readonly contentDigest: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function materializeRelease(packageRoot: string, dataDir: string, options?: MaterializeReleaseOptions): Promise<MaterializedRelease>;
|
|
7
19
|
export declare function readMaterializedRelease(releaseRoot: string): Promise<MaterializedRelease>;
|
|
20
|
+
/**
|
|
21
|
+
* Load metadata for a release whose bytes were already certified by the
|
|
22
|
+
* current parent process or an authenticated live supervisor. Callers must
|
|
23
|
+
* establish one of those preconditions; untrusted releases require full
|
|
24
|
+
* verification.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readCertifiedReleaseManifest(record: CertifiedReleaseRecord, selectedStoreRoot: string): Promise<MaterializedRelease>;
|
|
8
27
|
export declare function verifyMaterializedRelease(releaseRoot: string, expected?: ReleaseIdentity, selectedStoreRoot?: string): Promise<MaterializedRelease>;
|
|
9
28
|
export declare function assertImmutableExecutionRoot(release: MaterializedRelease, dataDir: string): Promise<void>;
|
|
10
29
|
export declare function resolveReleaseEntryPoint(release: MaterializedRelease, entryPoint: string): Promise<string>;
|
|
@@ -3,8 +3,10 @@ import { chmod, copyFile, lstat, mkdir, readFile, realpath, rename, rm, writeFil
|
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { PRODUCT_PACKAGE_NAME, deriveReleaseIdentity, resolveWithin } from "./release.js";
|
|
5
5
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
6
|
+
import { mapWithConcurrency } from "./concurrency.js";
|
|
7
|
+
const RELEASE_FILE_IO_CONCURRENCY = 32;
|
|
6
8
|
export const RELEASE_MANIFEST_FILENAME = PRODUCT_IDENTITY.manifest.releaseFilename;
|
|
7
|
-
export async function materializeRelease(packageRoot, dataDir) {
|
|
9
|
+
export async function materializeRelease(packageRoot, dataDir, options = {}) {
|
|
8
10
|
const identity = await deriveReleaseIdentity(packageRoot);
|
|
9
11
|
const storeRoot = resolve(dataDir, "releases");
|
|
10
12
|
await mkdir(storeRoot, { recursive: true, mode: 0o700 });
|
|
@@ -12,20 +14,31 @@ export async function materializeRelease(packageRoot, dataDir) {
|
|
|
12
14
|
const existing = await lstat(releaseRoot).catch(() => null);
|
|
13
15
|
if (existing)
|
|
14
16
|
return await verifyMaterializedRelease(releaseRoot, identity);
|
|
17
|
+
options.onProgress?.({ phase: "copying", fileCount: identity.files.length });
|
|
15
18
|
const candidate = resolveWithin(storeRoot, `.candidate-${identity.releaseId}-${randomUUID()}`);
|
|
16
19
|
await mkdir(candidate, { recursive: false, mode: 0o700 });
|
|
17
20
|
try {
|
|
18
|
-
|
|
21
|
+
const directories = [...new Set(identity.files.map(file => dirname(resolveWithin(candidate, file.path))))];
|
|
22
|
+
await mapWithConcurrency(directories, RELEASE_FILE_IO_CONCURRENCY, async (directory) => {
|
|
23
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
});
|
|
25
|
+
await mapWithConcurrency(identity.files, RELEASE_FILE_IO_CONCURRENCY, async (file) => {
|
|
19
26
|
const source = resolveWithin(identity.packageRoot, file.path);
|
|
20
27
|
const destination = resolveWithin(candidate, file.path);
|
|
21
|
-
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
22
28
|
await copyFile(source, destination);
|
|
23
29
|
await chmod(destination, file.executable ? 0o500 : 0o400);
|
|
24
|
-
}
|
|
30
|
+
});
|
|
25
31
|
await writeFile(resolve(candidate, RELEASE_MANIFEST_FILENAME), JSON.stringify(identity, null, 2), { mode: 0o400, flag: "wx" });
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
try {
|
|
33
|
+
await rename(candidate, releaseRoot);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!await lstat(releaseRoot).catch(() => null))
|
|
37
|
+
throw error;
|
|
38
|
+
await rm(candidate, { recursive: true, force: true });
|
|
39
|
+
return await verifyMaterializedRelease(releaseRoot, identity);
|
|
40
|
+
}
|
|
41
|
+
return { ...identity, releaseRoot: await realpath(releaseRoot) };
|
|
29
42
|
}
|
|
30
43
|
catch (error) {
|
|
31
44
|
await rm(candidate, { recursive: true, force: true });
|
|
@@ -37,6 +50,25 @@ export async function readMaterializedRelease(releaseRoot) {
|
|
|
37
50
|
const manifest = JSON.parse(await readFile(resolve(canonical, RELEASE_MANIFEST_FILENAME), "utf8"));
|
|
38
51
|
return await verifyMaterializedRelease(canonical, manifest);
|
|
39
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Load metadata for a release whose bytes were already certified by the
|
|
55
|
+
* current parent process or an authenticated live supervisor. Callers must
|
|
56
|
+
* establish one of those preconditions; untrusted releases require full
|
|
57
|
+
* verification.
|
|
58
|
+
*/
|
|
59
|
+
export async function readCertifiedReleaseManifest(record, selectedStoreRoot) {
|
|
60
|
+
const canonical = await realpath(record.releaseRoot);
|
|
61
|
+
assertContained(await realpath(selectedStoreRoot), canonical, "release root is outside the selected release store");
|
|
62
|
+
const manifest = JSON.parse(await readFile(resolveWithin(canonical, RELEASE_MANIFEST_FILENAME), "utf8"));
|
|
63
|
+
validateManifest(manifest);
|
|
64
|
+
if (manifest.releaseId !== record.releaseId || manifest.contentDigest !== record.contentDigest
|
|
65
|
+
|| (record.packageVersion !== undefined && manifest.packageVersion !== record.packageVersion)) {
|
|
66
|
+
throw new Error(`certified release record differs from manifest for ${canonical}`);
|
|
67
|
+
}
|
|
68
|
+
if (canonical.split(sep).at(-1) !== manifest.releaseId)
|
|
69
|
+
throw new Error(`release directory does not match identity ${manifest.releaseId}`);
|
|
70
|
+
return { ...manifest, releaseRoot: canonical };
|
|
71
|
+
}
|
|
40
72
|
export async function verifyMaterializedRelease(releaseRoot, expected, selectedStoreRoot) {
|
|
41
73
|
const canonical = await realpath(releaseRoot);
|
|
42
74
|
if (selectedStoreRoot)
|
|
@@ -50,15 +82,17 @@ export async function verifyMaterializedRelease(releaseRoot, expected, selectedS
|
|
|
50
82
|
if (canonical.split(sep).at(-1) !== manifest.releaseId && !canonical.split(sep).at(-1)?.startsWith(`.candidate-${manifest.releaseId}-`)) {
|
|
51
83
|
throw new Error(`release directory does not match identity ${manifest.releaseId}`);
|
|
52
84
|
}
|
|
53
|
-
|
|
85
|
+
await mapWithConcurrency(manifest.files, RELEASE_FILE_IO_CONCURRENCY, async (file) => {
|
|
54
86
|
await verifyFile(canonical, file);
|
|
87
|
+
});
|
|
55
88
|
const recomputed = digestManifestFiles(manifest.files);
|
|
56
89
|
if (recomputed !== manifest.contentDigest)
|
|
57
90
|
throw new Error(`release content digest mismatch for ${manifest.releaseId}`);
|
|
58
91
|
return { ...manifest, releaseRoot: canonical };
|
|
59
92
|
}
|
|
60
93
|
export async function assertImmutableExecutionRoot(release, dataDir) {
|
|
61
|
-
await
|
|
94
|
+
const storeRoot = await realpath(resolve(dataDir, "releases"));
|
|
95
|
+
assertContained(storeRoot, release.releaseRoot, "release root is outside the selected release store");
|
|
62
96
|
const selectedRoot = process.env[PRODUCT_IDENTITY.environment.releaseRoot];
|
|
63
97
|
if (!selectedRoot)
|
|
64
98
|
throw new Error(PRODUCT_TEXT.diagnostic("persistent process has no immutable release root"));
|