@haywoodmarx/agent-pager 0.1.3 → 0.2.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/README.md +4 -4
- package/dist/src/broker/address.d.ts +7 -0
- package/dist/src/broker/address.js +9 -0
- package/dist/src/broker/broker.d.ts +91 -23
- package/dist/src/broker/broker.js +505 -123
- package/dist/src/broker/operator-projection.js +3 -5
- package/dist/src/broker/protocol.d.ts +1 -1
- package/dist/src/broker/protocol.js +1 -1
- package/dist/src/broker/schema.d.ts +4 -0
- package/dist/src/broker/schema.js +9 -6
- package/dist/src/broker/snapshot.d.ts +4 -2
- package/dist/src/implementations/claude/gateway-cli.js +1 -2
- package/dist/src/implementations/claude/gateway.d.ts +11 -4
- package/dist/src/implementations/claude/gateway.js +44 -14
- package/dist/src/implementations/claude/instructions.js +1 -1
- package/dist/src/implementations/model.d.ts +6 -3
- package/dist/src/implementations/opencode/plugin.d.ts +7 -2
- package/dist/src/implementations/opencode/plugin.js +103 -7
- package/dist/src/implementations/pi/broker-client.d.ts +13 -4
- package/dist/src/implementations/pi/broker-client.js +71 -9
- package/dist/src/implementations/pi/extension.d.ts +7 -2
- package/dist/src/implementations/pi/extension.js +15 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/launch/broker-lock.d.ts +58 -4
- package/dist/src/launch/broker-lock.js +140 -7
- package/dist/src/launch/launch.d.ts +1 -1
- package/dist/src/launch/on-demand.d.ts +8 -8
- package/dist/src/launch/on-demand.js +54 -35
- package/dist/src/launch/runtime-composition.js +2 -0
- package/dist/src/launch/runtime.js +4 -14
- package/dist/src/launch/service.d.ts +1 -0
- package/dist/src/launch/service.js +23 -1
- package/dist/src/launch/workspace-branch.d.ts +3 -3
- package/dist/src/launch/workspace-branch.js +7 -15
- package/dist/src/launch/workspace-root.d.ts +13 -0
- package/dist/src/launch/workspace-root.js +27 -0
- package/dist/src/messaging/delivery-client.d.ts +17 -0
- package/dist/src/messaging/delivery-client.js +31 -1
- package/dist/src/messaging/identity-statement.d.ts +7 -0
- package/dist/src/messaging/identity-statement.js +8 -0
- package/dist/src/messaging/mcp.d.ts +8 -2
- package/dist/src/messaging/mcp.js +54 -1
- package/dist/src/messaging/notifications.d.ts +31 -3
- package/dist/src/messaging/notifications.js +25 -0
- package/dist/src/messaging/push-delivery.d.ts +2 -1
- package/dist/src/messaging/push-delivery.js +24 -7
- package/dist/src/messaging/tools.js +22 -2
- package/dist/src/messaging/wake.js +7 -0
- package/dist/src/operator/agent-projection.d.ts +8 -5
- package/dist/src/operator/agent-projection.js +29 -9
- package/dist/src/operator/protocol.d.ts +2 -2
- package/dist/src/operator/repository-system.d.ts +29 -0
- package/dist/src/operator/repository-system.js +39 -12
- package/dist/src/operator/repository.d.ts +28 -11
- package/dist/src/operator/repository.js +37 -4
- package/dist/src/shared/versions.d.ts +18 -0
- package/dist/src/shared/versions.js +26 -0
- package/dist/src/tui/agent-tree.js +2 -2
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +14 -6
- package/package.json +1 -1
- package/skills/pager/SKILL.md +35 -45
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { openBrokerDatabase, } from "./database.js";
|
|
4
4
|
import { decodeMessageBody, encodeMessageBody, issueOpaqueToken, assertAgentId, FETCH_LIMIT_DEFAULT, isAgentId, isDirectAgentAddress, mintAgentId, normalizeMessageSubject, normalizeMessageType, ProtocolError, validateImplementationId, validateSessionKey, } from "./protocol.js";
|
|
5
5
|
import { PAGER_DEFAULTS } from "../configuration-defaults.js";
|
|
6
6
|
import { publicEpoch } from "./snapshot.js";
|
|
7
|
-
import { normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
|
|
8
|
-
import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES } from "./schema.js";
|
|
7
|
+
import { formatAgentAddress, normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
|
|
8
|
+
import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES, } from "./schema.js";
|
|
9
9
|
import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.js";
|
|
10
10
|
const notificationDisplay = {
|
|
11
11
|
delivered: { text: "notify delivered", tone: "ok" },
|
|
@@ -104,6 +104,7 @@ export class Broker {
|
|
|
104
104
|
#epochOwner;
|
|
105
105
|
#lifecycleSynchronization;
|
|
106
106
|
#workspaceBranch;
|
|
107
|
+
#workspaceRoot;
|
|
107
108
|
#report;
|
|
108
109
|
#projection;
|
|
109
110
|
#commitListeners = new Set();
|
|
@@ -113,12 +114,13 @@ export class Broker {
|
|
|
113
114
|
/**
|
|
114
115
|
* Opens a durable broker database with the supplied timing and routing policies.
|
|
115
116
|
* @param path - SQLite database location, or memory sentinel, for durable state.
|
|
116
|
-
* @param options - Clock, workspace-branch
|
|
117
|
+
* @param options - Clock, workspace-branch and workspace-root lookups, and diagnostics sink are required; see {@link BrokerOptions} for its optional fields.
|
|
117
118
|
*/
|
|
118
119
|
constructor(path, options) {
|
|
119
120
|
this.#db = (options.openDatabase ?? openBrokerDatabase)(path);
|
|
120
121
|
this.#now = options.now;
|
|
121
122
|
this.#workspaceBranch = options.workspaceBranch;
|
|
123
|
+
this.#workspaceRoot = options.workspaceRoot;
|
|
122
124
|
this.#report = options.report;
|
|
123
125
|
this.#visibilityTimeoutMs = options.visibilityTimeoutMs ?? PAGER_DEFAULTS.visibilityTimeoutMs;
|
|
124
126
|
this.#redeliveryBaseDelayMs = options.redeliveryBaseDelayMs ?? PAGER_DEFAULTS.redeliveryBaseDelayMs;
|
|
@@ -196,34 +198,34 @@ export class Broker {
|
|
|
196
198
|
}
|
|
197
199
|
/**
|
|
198
200
|
* Register a concrete agent under one workspace-unique session name.
|
|
199
|
-
* @param args - Caller-supplied identity, topology, and implementation values.
|
|
201
|
+
* @param args - Caller-supplied identity, topology, and implementation values. `workspacePath` is
|
|
202
|
+
* the directory the agent works in, which registers it in the workspace that directory belongs to.
|
|
200
203
|
* @returns - Stable agent identity registered in the requested team.
|
|
201
204
|
*/
|
|
202
205
|
registerAgent(args) {
|
|
203
|
-
const workspacePath = resolve(args.workspacePath);
|
|
204
206
|
const identity = this.#registrationIdentity(args.agentId, args.sessionName, args.implementation);
|
|
205
|
-
// Validated before the
|
|
206
|
-
const
|
|
207
|
-
return this.#transaction(() => this.#registerAgent(args, identity,
|
|
207
|
+
// Validated before the ports run, so a rejected registration never pays for a git subprocess.
|
|
208
|
+
const location = this.#locate(resolve(args.workspacePath));
|
|
209
|
+
return this.#transaction(() => this.#registerAgent(args, identity, location));
|
|
208
210
|
}
|
|
209
211
|
/**
|
|
210
212
|
* Register or rejoin an implementation identity without a prior launch reservation.
|
|
211
213
|
*
|
|
212
|
-
* A `sessionKey` makes the identity resumable: the same harness conversation returning
|
|
213
|
-
* restart resolves to the identity it already owns, keeping its alias,
|
|
214
|
-
* the caller passes as `sessionName`. Without one, rejoining still
|
|
214
|
+
* A `sessionKey` makes the identity resumable: the same harness conversation returning to the same
|
|
215
|
+
* working directory after a restart resolves to the identity it already owns, keeping its alias,
|
|
216
|
+
* inbox, and traffic, whatever the caller passes as `sessionName`. Without one, rejoining still
|
|
217
|
+
* falls back to matching the alias.
|
|
215
218
|
*
|
|
216
219
|
* A `continuesSessionKey` is the key a conversation was known by before it continued under
|
|
217
220
|
* `sessionKey`. When `sessionKey` owns no identity yet, the identity the continued key owns moves
|
|
218
221
|
* to `sessionKey`, its holder displaced, so the alias, inbox, and traffic follow the conversation.
|
|
219
|
-
* @param args -
|
|
220
|
-
*
|
|
222
|
+
* @param args - Directory the agent works in, which registers it in the workspace that directory
|
|
223
|
+
* belongs to; implementation; optional alias; optional resumable key; and the key of the
|
|
224
|
+
* conversation that one continues, which requires the resumable key.
|
|
221
225
|
* @returns - Stable agent identity, its current alias, and a fresh single-use attachment reservation.
|
|
222
226
|
*/
|
|
223
227
|
registerAgentWithoutReservation(args) {
|
|
224
|
-
const
|
|
225
|
-
const workspaceId = workspaceIdentity(workspacePath);
|
|
226
|
-
const teamId = workspaceTeamId(workspacePath);
|
|
228
|
+
const workingDirectory = resolve(args.workspacePath);
|
|
227
229
|
const ephemeral = args.sessionName === undefined;
|
|
228
230
|
validateImplementationId(args.implementation.id);
|
|
229
231
|
const sessionKey = args.sessionKey === undefined ? undefined : validateSessionKey(args.sessionKey);
|
|
@@ -231,32 +233,38 @@ export class Broker {
|
|
|
231
233
|
if (continuesSessionKey !== undefined && sessionKey === undefined) {
|
|
232
234
|
throw new ProtocolError("validation_failed", "A continued session key requires the conversation's own session key");
|
|
233
235
|
}
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
236
|
+
const keys = { implementationId: args.implementation.id, sessionKey, continuesSessionKey };
|
|
237
|
+
// Read ahead of the workspace lookup, which a resume skips when it keeps the workspace it registered in.
|
|
238
|
+
// Re-read inside the transaction, where a registration that loses the race to resume takes the
|
|
239
|
+
// location this identity registered from.
|
|
240
|
+
const resumableAhead = this.#resumableIdentity(workingDirectory, keys);
|
|
241
|
+
const registered = resumableAhead && this.#registeredLocation(resumableAhead.agent.agentId);
|
|
242
|
+
const registeredAhead = resumableAhead && registered
|
|
243
|
+
? { agentId: resumableAhead.agent.agentId, workspaceRoot: registered.workspaceRoot }
|
|
241
244
|
: undefined;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
const location = registered
|
|
246
|
+
? {
|
|
247
|
+
...registered,
|
|
248
|
+
workspaceRoot: this.#confirmedWorkspaceRoot(registered),
|
|
249
|
+
branch: this.#workspaceBranch(workingDirectory),
|
|
250
|
+
}
|
|
251
|
+
: this.#locate(workingDirectory);
|
|
252
|
+
const workspaceId = workspaceIdentity(location.workspaceRoot);
|
|
253
|
+
const teamId = workspaceTeamId(location.workspaceRoot);
|
|
245
254
|
return this.#transaction(() => {
|
|
246
|
-
const
|
|
247
|
-
const continued = sessionKey && !owned && continuesSessionKey
|
|
248
|
-
? this.#agentForSessionKey(workspaceId, args.implementation.id, continuesSessionKey)
|
|
249
|
-
: undefined;
|
|
250
|
-
if (sessionKey && continued)
|
|
251
|
-
this.#bindSessionKey(continued.agentId, sessionKey, false);
|
|
252
|
-
const resumable = owned ?? continued;
|
|
255
|
+
const resumable = this.#resumableIdentity(workingDirectory, keys);
|
|
253
256
|
if (resumable) {
|
|
254
|
-
|
|
257
|
+
const { agent } = resumable;
|
|
258
|
+
if (sessionKey && resumable.byContinuedKey)
|
|
259
|
+
this.#bindSessionKey(agent.agentId, sessionKey, false);
|
|
260
|
+
this.#displaceHolder(agent.agentId);
|
|
261
|
+
this.#recordBranch(agent.agentId, location.branch);
|
|
262
|
+
this.#followWorkspace(agent, this.#workspaceRootToFollow(agent.agentId, registeredAhead, location));
|
|
255
263
|
return {
|
|
256
|
-
agentId:
|
|
257
|
-
reservation: this.#reserveAttachment(
|
|
258
|
-
sessionName:
|
|
259
|
-
ephemeral:
|
|
264
|
+
agentId: agent.agentId,
|
|
265
|
+
reservation: this.#reserveAttachment(agent.agentId, "messaging"),
|
|
266
|
+
sessionName: agent.sessionName,
|
|
267
|
+
ephemeral: agent.ephemeral,
|
|
260
268
|
resumed: true,
|
|
261
269
|
};
|
|
262
270
|
}
|
|
@@ -265,13 +273,12 @@ export class Broker {
|
|
|
265
273
|
// request over a name it will not be given would cost it that identity, its inbox and its traffic.
|
|
266
274
|
const sessionName = validateRegistrationSessionName(args.sessionName ?? this.#nextImplementationAlias(workspaceId, args.implementation.id));
|
|
267
275
|
const agentId = this.#registerAgentWithoutReservation({
|
|
268
|
-
|
|
276
|
+
location,
|
|
269
277
|
workspaceId,
|
|
270
278
|
teamId,
|
|
271
279
|
sessionName,
|
|
272
280
|
ephemeral,
|
|
273
281
|
implementation: args.implementation,
|
|
274
|
-
branch,
|
|
275
282
|
});
|
|
276
283
|
if (sessionKey)
|
|
277
284
|
this.#bindSessionKey(agentId, sessionKey, true);
|
|
@@ -291,66 +298,272 @@ export class Broker {
|
|
|
291
298
|
* @returns - Current identity and alias history after the replacement.
|
|
292
299
|
*/
|
|
293
300
|
updateAgentAlias(agentId, requestedAlias) {
|
|
294
|
-
|
|
301
|
+
return this.relocateAgent(agentId, { alias: requestedAlias }).identity;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Move an agent to another workspace, rename it, or both, in one step.
|
|
305
|
+
*
|
|
306
|
+
* The alias must be free in the target workspace: held by nobody else and reserved as nobody
|
|
307
|
+
* else's retired name. Without `force` a taken alias fails with `alias_conflict` naming its holder
|
|
308
|
+
* and changes nothing. With `force` the caller takes the holder's identity over: its id, alias,
|
|
309
|
+
* inbox and alias history become the caller's, and the identity joins the target workspace, which
|
|
310
|
+
* is the caller's own when the request names none; the caller's own connections and
|
|
311
|
+
* conversation key move onto it, and the caller's previous identity is retired with its messages
|
|
312
|
+
* moved across and its aliases reserved as redirects to the claimed identity. The holder's own
|
|
313
|
+
* sessions and unconsumed attach reservations end, so a session or resume token it presents is
|
|
314
|
+
* refused as expired, and a claim token it held acknowledges nothing.
|
|
315
|
+
*
|
|
316
|
+
* A move keeps every alias the agent held reserved in the workspace it leaves, redirecting to its
|
|
317
|
+
* current address, and carries its alias history into the workspace it joins.
|
|
318
|
+
* @param agentId - Authenticated identity asking to move or rename.
|
|
319
|
+
* @param request - Target workspace, alias, and whether to take the alias over from its holder.
|
|
320
|
+
* @returns - The identity the caller now acts as, and the takeover when one was performed.
|
|
321
|
+
*/
|
|
322
|
+
relocateAgent(agentId, request) {
|
|
323
|
+
const sessionName = request.alias === undefined
|
|
324
|
+
? undefined
|
|
325
|
+
: validateRegistrationSessionName(request.alias.replace(/^@/u, ""), "agent alias");
|
|
326
|
+
const names = this.workspaceNames();
|
|
327
|
+
// Resolved before the write transaction opens: a directory lookup may block on a subprocess.
|
|
328
|
+
const requestedRoot = request.workspace === undefined ? undefined : this.#requestedWorkspaceRoot(request.workspace, names);
|
|
295
329
|
return this.#transaction(() => {
|
|
296
330
|
const agent = this.#activeAgent(agentId);
|
|
297
331
|
this.#lifecycleSynchronization?.checkpoint("agent_alias_active");
|
|
298
|
-
const
|
|
299
|
-
this.#
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
this.#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.#
|
|
332
|
+
const targetName = sessionName ?? String(agent.registration_name);
|
|
333
|
+
const workspaceRoot = requestedRoot ?? this.#workspacePath(String(agent.workspace_id));
|
|
334
|
+
const workspaceId = workspaceIdentity(workspaceRoot);
|
|
335
|
+
const holder = this.#aliasHolder(workspaceId, targetName, agentId);
|
|
336
|
+
if (holder && request.force !== true)
|
|
337
|
+
throw this.#aliasHeldError(targetName, holder, names);
|
|
338
|
+
const claim = holder ? this.#claimIdentity(agentId, holder.agentId, names) : null;
|
|
339
|
+
const actingId = claim ? claim.agentId : agentId;
|
|
340
|
+
this.#moveToWorkspace(actingId, workspaceRoot, targetName);
|
|
341
|
+
this.#renameAgent(actingId, workspaceId, targetName);
|
|
307
342
|
this.#lifecycleSynchronization?.checkpoint("agent_alias_updated");
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
id: agentId,
|
|
313
|
-
alias: normalizeAlias(sessionName),
|
|
314
|
-
previousAliases: previous.map((row) => String(row.alias)),
|
|
315
|
-
};
|
|
343
|
+
// A trigger on the rename can reap the agent within the transaction, so its row is not read back.
|
|
344
|
+
// Joining a workspace no agent had registered in can change the names of the others.
|
|
345
|
+
const namesNow = requestedRoot === undefined ? names : this.workspaceNames();
|
|
346
|
+
return { identity: this.#identity(actingId, workspaceId, targetName, namesNow), claim };
|
|
316
347
|
});
|
|
317
348
|
}
|
|
318
349
|
/**
|
|
319
|
-
*
|
|
350
|
+
* Resolve the workspace a relocation names to the path that identifies it.
|
|
351
|
+
*
|
|
352
|
+
* A name a listing reports wins over a directory of the same spelling; the two agree whenever
|
|
353
|
+
* both apply, since a name spanning a whole path is that path.
|
|
354
|
+
* @param requested - Workspace name, or absolute directory.
|
|
355
|
+
* @param names - Workspace names as {@link Broker.workspaceNames} reports them.
|
|
356
|
+
* @returns - Path of the named workspace, or the path naming the workspace the directory belongs to.
|
|
357
|
+
*/
|
|
358
|
+
#requestedWorkspaceRoot(requested, names) {
|
|
359
|
+
const named = this.#workspacePathForName(requested, names);
|
|
360
|
+
if (named !== undefined)
|
|
361
|
+
return named;
|
|
362
|
+
if (isAbsolute(requested))
|
|
363
|
+
return this.#workspaceRootOf(resolve(requested));
|
|
364
|
+
throw new ProtocolError("validation_failed", `Unknown workspace ${requested}; name one a listing reports, or give an absolute directory`);
|
|
365
|
+
}
|
|
366
|
+
#workspacePathForName(requested, names) {
|
|
367
|
+
let name;
|
|
368
|
+
try {
|
|
369
|
+
name = parseWorkspaceName(requested);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
for (const [workspaceId, workspaceName] of names) {
|
|
375
|
+
if (workspaceName === name)
|
|
376
|
+
return this.#workspacePath(workspaceId);
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
#workspacePath(workspaceId) {
|
|
381
|
+
const row = this.#db.prepare("select path from workspaces where id = ?").get(workspaceId);
|
|
382
|
+
if (row === undefined)
|
|
383
|
+
throw new Error(`Workspace ${workspaceId} is not recorded`);
|
|
384
|
+
return String(row.path);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Find who stands between an agent and an alias in a workspace.
|
|
388
|
+
* @param workspaceId - Workspace the alias would be held in.
|
|
389
|
+
* @param sessionName - Alias asked for, without its at-sign.
|
|
390
|
+
* @param owner - Agent asking, whose own registration and retired names are not in its way, or
|
|
391
|
+
* nothing for an agent not yet registered.
|
|
392
|
+
* @returns - The identity registered under the alias or holding it as a retired name, or nothing.
|
|
393
|
+
*/
|
|
394
|
+
#aliasHolder(workspaceId, sessionName, owner) {
|
|
395
|
+
const registration = this.#db
|
|
396
|
+
.prepare("select id from agents where workspace_id = ? and registration_name = ? and state != 'reaped'")
|
|
397
|
+
.get(workspaceId, sessionName);
|
|
398
|
+
if (registration && registration.id !== owner) {
|
|
399
|
+
return { agentId: readStoredAgentId(registration.id, "agents.id"), retired: false };
|
|
400
|
+
}
|
|
401
|
+
const historical = this.#db
|
|
402
|
+
.prepare("select h.agent_id from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
|
|
403
|
+
.get(workspaceId, normalizeAlias(sessionName));
|
|
404
|
+
if (historical && historical.agent_id !== owner) {
|
|
405
|
+
return { agentId: readStoredAgentId(historical.agent_id, "alias_history.agent_id"), retired: true };
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
#aliasHeldError(sessionName, holder, names) {
|
|
410
|
+
const described = this.#describeAgent(holder.agentId, names);
|
|
411
|
+
return new ProtocolError("alias_conflict", holder.retired
|
|
412
|
+
? `Agent alias ${normalizeAlias(sessionName)} is a retired name that stays reserved in this workspace for ${described}; pass force to claim it`
|
|
413
|
+
: `Agent alias ${normalizeAlias(sessionName)} is already in use in this workspace by ${described}; pass force to claim it`);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Name an identity as an error does: its address, then its implementation and id in parentheses.
|
|
417
|
+
* @param agentId - Identity to describe.
|
|
418
|
+
* @param names - Workspace names as {@link Broker.workspaceNames} reports them.
|
|
419
|
+
* @returns - `address (implementation, id)`, or `address (id)` when no implementation is recorded.
|
|
420
|
+
*/
|
|
421
|
+
#describeAgent(agentId, names) {
|
|
422
|
+
const row = this.#db.prepare("select implementation_id from agents where id = ?").get(agentId);
|
|
423
|
+
const implementation = typeof row?.implementation_id === "string" ? `${row.implementation_id}, ` : "";
|
|
424
|
+
return `${this.#agentAddress(agentId, names)} (${implementation}${agentId})`;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Give an identity a new registration name in the workspace it is in, retiring the one it had.
|
|
428
|
+
*
|
|
429
|
+
* The retired name stays reserved as a redirect to this identity. A reservation this identity held
|
|
430
|
+
* on the new name is released, since the name is current again.
|
|
431
|
+
* @param agentId - Identity being renamed.
|
|
432
|
+
* @param workspaceId - Workspace the identity is in.
|
|
433
|
+
* @param sessionName - Name to register, already validated.
|
|
434
|
+
*/
|
|
435
|
+
#renameAgent(agentId, workspaceId, sessionName) {
|
|
436
|
+
const row = this.#db.prepare("select registration_name from agents where id = ?").get(agentId);
|
|
437
|
+
const currentAlias = normalizeAlias(String(row?.registration_name));
|
|
438
|
+
const nextAlias = normalizeAlias(sessionName);
|
|
439
|
+
if (currentAlias !== nextAlias)
|
|
440
|
+
this.#reserveAlias(workspaceId, currentAlias, agentId);
|
|
441
|
+
this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
|
|
442
|
+
this.#write("update agents set registration_name = ?, registration_ephemeral = 0 where id = ?", sessionName, agentId);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Drop the redirect an identity holds from an alias it now registers under, since the name is current again.
|
|
446
|
+
* @param agentId - Identity registered under the alias.
|
|
447
|
+
* @param workspaceId - Workspace the identity is in.
|
|
448
|
+
* @param alias - Alias with its at-sign.
|
|
449
|
+
*/
|
|
450
|
+
#releaseOwnReservation(agentId, workspaceId, alias) {
|
|
451
|
+
this.#write("delete from alias_history where workspace_id = ? and alias = ? and agent_id = ?", workspaceId, alias, agentId);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Reserve a retired alias in a workspace as a redirect to an identity, unless another identity is
|
|
455
|
+
* registered under it there or already holds it as a retired name.
|
|
456
|
+
* @param workspaceId - Workspace the alias is reserved in.
|
|
457
|
+
* @param alias - Alias with its at-sign.
|
|
458
|
+
* @param agentId - Identity a send to the alias is redirected to.
|
|
459
|
+
*/
|
|
460
|
+
#reserveAlias(workspaceId, alias, agentId) {
|
|
461
|
+
this.#write("insert or ignore into alias_history(workspace_id, alias, agent_id) select ?, ?, ? where not exists (select 1 from agents where workspace_id = ? and registration_name = ? and state != 'reaped' and id != ?)", workspaceId, alias, agentId, workspaceId, alias.slice(1), agentId);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Take over another identity for the caller's process.
|
|
465
|
+
*
|
|
466
|
+
* The claimed identity keeps its id, alias, inbox, history and workspace, and takes the caller's
|
|
467
|
+
* working directory, branch, implementation, parent and conversation key. The holder's sessions
|
|
468
|
+
* and unconsumed attach reservations end, and no identity is created for it.
|
|
469
|
+
* @param claimantId - Active identity giving itself up.
|
|
470
|
+
* @param holderId - Identity being taken over.
|
|
471
|
+
* @param names - Workspace names as {@link Broker.workspaceNames} reports them, as the addresses are read.
|
|
472
|
+
* @returns - What was taken and what was given up.
|
|
473
|
+
*/
|
|
474
|
+
#claimIdentity(claimantId, holderId, names) {
|
|
475
|
+
const claimant = this.#activeAgent(claimantId);
|
|
476
|
+
const holder = this.#agent(holderId);
|
|
477
|
+
const address = this.#agentAddress(holderId, names);
|
|
478
|
+
const retiredAddress = this.#agentAddress(claimantId, names);
|
|
479
|
+
this.#endHolderSessions(holderId);
|
|
480
|
+
const claimantWorkspaceId = String(claimant.workspace_id);
|
|
481
|
+
const holderWorkspaceId = String(holder.workspace_id);
|
|
482
|
+
// The claimant's row is retired before the claimed one takes its conversation key: the key is
|
|
483
|
+
// unique among active identities of one directory and implementation.
|
|
484
|
+
this.#write("update agents set state = 'reaped', session_key = null, archived_at = null where id = ?", claimantId);
|
|
485
|
+
this.#write("update alias_history set agent_id = ? where agent_id = ?", holderId, claimantId);
|
|
486
|
+
this.#reserveAlias(claimantWorkspaceId, normalizeAlias(String(claimant.registration_name)), holderId);
|
|
487
|
+
if (claimantWorkspaceId !== holderWorkspaceId) {
|
|
488
|
+
this.#copyAliasHistory(holderId, claimantWorkspaceId, holderWorkspaceId);
|
|
489
|
+
}
|
|
490
|
+
this.#moveTraffic(claimantId, holderId);
|
|
491
|
+
this.#write("update sessions set agent_id = ? where agent_id = ?", holderId, claimantId);
|
|
492
|
+
this.#write("update reservations set agent_id = ? where agent_id = ?", holderId, claimantId);
|
|
493
|
+
this.#write("update agents set state = 'active', archived_at = null, registration_ephemeral = 0, working_directory = ?, branch = ?, parent_id = ?, implementation_id = ?, implementation_version = ?, session_key = ? where id = ?", String(claimant.working_directory), textOrNull(claimant.branch), textOrNull(claimant.parent_id), textOrNull(claimant.implementation_id), textOrNull(claimant.implementation_version), textOrNull(claimant.session_key), holderId);
|
|
494
|
+
return { address, agentId: holderId, retiredId: claimantId, retiredAddress };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* End every session and unconsumed attach reservation of an identity being claimed, recording
|
|
498
|
+
* the claim as the cause on each session it ends, so every session or resume token its process
|
|
499
|
+
* presents is refused as expired.
|
|
500
|
+
* @param holderId - Identity being claimed.
|
|
501
|
+
*/
|
|
502
|
+
#endHolderSessions(holderId) {
|
|
503
|
+
const cause = "claimed";
|
|
504
|
+
this.#write("update sessions set end_cause = ? where agent_id = ? and active = 1", cause, holderId);
|
|
505
|
+
this.#displaceHolder(holderId);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Move every message one identity sent or was sent, with the claims and send keys on them, to
|
|
509
|
+
* another, so the second can acknowledge, read status and repeat sends the first could.
|
|
510
|
+
* @param fromId - Identity giving its traffic up.
|
|
511
|
+
* @param toId - Identity receiving it.
|
|
512
|
+
*/
|
|
513
|
+
#moveTraffic(fromId, toId) {
|
|
514
|
+
// A send key names one sender, recipient and key; the receiving identity's own record wins a tie.
|
|
515
|
+
this.#write("delete from idempotency where agent_id = ? and exists (select 1 from idempotency i where i.agent_id = ? and i.recipient_id = idempotency.recipient_id and i.key = idempotency.key)", fromId, toId);
|
|
516
|
+
this.#write("delete from idempotency where recipient_id = ? and exists (select 1 from idempotency i where i.recipient_id = ? and i.agent_id = idempotency.agent_id and i.key = idempotency.key)", fromId, toId);
|
|
517
|
+
this.#write("update idempotency set agent_id = ? where agent_id = ?", toId, fromId);
|
|
518
|
+
this.#write("update idempotency set recipient_id = ? where recipient_id = ?", toId, fromId);
|
|
519
|
+
this.#write("update messages set sender_id = ? where sender_id = ?", toId, fromId);
|
|
520
|
+
this.#write("update messages set recipient_id = ? where recipient_id = ?", toId, fromId);
|
|
521
|
+
this.#write("update claims set agent_id = ? where agent_id = ?", toId, fromId);
|
|
522
|
+
this.#write("delete from wakes where agent_id = ?", fromId);
|
|
523
|
+
this.#syncRedelivery(toId);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Return an agent's identity as reported to that agent.
|
|
320
527
|
* @param agentId - Stable identity to inspect.
|
|
321
528
|
* @returns - Identity details safe to return to the owning agent.
|
|
322
529
|
*/
|
|
323
530
|
agentIdentity(agentId) {
|
|
324
531
|
const agent = this.#agent(agentId);
|
|
532
|
+
return this.#identity(agentId, String(agent.workspace_id), String(agent.registration_name));
|
|
533
|
+
}
|
|
534
|
+
#identity(agentId, workspaceId, registrationName, names = this.workspaceNames()) {
|
|
325
535
|
const previous = this.#db
|
|
326
536
|
.prepare("select alias from alias_history where workspace_id = ? and agent_id = ? order by id")
|
|
327
|
-
.all(
|
|
537
|
+
.all(workspaceId, agentId);
|
|
538
|
+
const alias = normalizeAlias(registrationName);
|
|
539
|
+
const workspace = names.get(workspaceId) ?? workspaceId;
|
|
328
540
|
return {
|
|
329
541
|
id: agentId,
|
|
330
|
-
alias
|
|
542
|
+
alias,
|
|
543
|
+
workspace,
|
|
544
|
+
address: formatAgentAddress(alias, workspace),
|
|
331
545
|
previousAliases: previous.map((row) => String(row.alias)),
|
|
332
546
|
};
|
|
333
547
|
}
|
|
334
548
|
/**
|
|
335
549
|
* Reserve an identity and single-use attach reservation for a future process.
|
|
336
|
-
* @param args - Registration name, team, and
|
|
550
|
+
* @param args - Registration name, team, and the directory the future process works in, which
|
|
551
|
+
* creates the reservation in the workspace that directory belongs to.
|
|
337
552
|
* @returns - Reservation identity and one-time reservation for the future agent.
|
|
338
553
|
*/
|
|
339
554
|
reserveAgent(args) {
|
|
340
|
-
const workspacePath = resolve(args.workspacePath);
|
|
341
555
|
// A reservation is made for a process that has not started, so a caller with no name to give may
|
|
342
556
|
// send an empty one and mean "derive it"; only a name with content is held to the alias rule.
|
|
343
557
|
const requestedName = args.sessionName === undefined || args.sessionName === "" ? undefined : args.sessionName;
|
|
344
558
|
const identity = this.#registrationIdentity(undefined, requestedName, args.implementation);
|
|
345
|
-
// Validated before the
|
|
346
|
-
const
|
|
559
|
+
// Validated before the ports run, so a rejected reservation never pays for a git subprocess.
|
|
560
|
+
const location = this.#locate(resolve(args.workspacePath));
|
|
347
561
|
return this.#transaction(() => {
|
|
348
562
|
const agentId = this.#registerAgent({
|
|
349
563
|
teamId: args.teamId,
|
|
350
564
|
parentId: args.parentId,
|
|
351
|
-
workspacePath: args.workspacePath,
|
|
352
565
|
...(args.implementation !== undefined ? { implementation: args.implementation } : {}),
|
|
353
|
-
}, identity,
|
|
566
|
+
}, identity, location);
|
|
354
567
|
return { agentId, reservation: this.#reserveAttachment(agentId, "messaging") };
|
|
355
568
|
});
|
|
356
569
|
}
|
|
@@ -429,14 +642,14 @@ export class Broker {
|
|
|
429
642
|
const key = validateSessionKey(sessionKey);
|
|
430
643
|
return this.#transaction(() => {
|
|
431
644
|
const agent = this.#db
|
|
432
|
-
.prepare("select
|
|
645
|
+
.prepare("select working_directory, implementation_id from agents where id = ? and state = 'active'")
|
|
433
646
|
.get(agentId);
|
|
434
647
|
if (!agent)
|
|
435
648
|
throw new ProtocolError("conflict", "Only an active agent identity can claim a session key");
|
|
436
649
|
if (typeof agent.implementation_id !== "string") {
|
|
437
650
|
throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
|
|
438
651
|
}
|
|
439
|
-
const owner = this.#agentForSessionKey(String(agent.
|
|
652
|
+
const owner = this.#agentForSessionKey(String(agent.working_directory), agent.implementation_id, key);
|
|
440
653
|
if (!owner || owner.agentId === agentId) {
|
|
441
654
|
this.#bindSessionKey(agentId, key, false);
|
|
442
655
|
return { agentId, adopted: false };
|
|
@@ -728,12 +941,15 @@ export class Broker {
|
|
|
728
941
|
* Name why a known session no longer resolves.
|
|
729
942
|
* @param row - The session row the caller presented, inactive or past its lease.
|
|
730
943
|
* @returns - `ended` when the newest generation of the row's rotation lineage was deactivated by an
|
|
731
|
-
* operator archive or by a newer attachment of the same identity and channel, otherwise `expired
|
|
944
|
+
* operator archive or by a newer attachment of the same identity and channel, otherwise `expired`,
|
|
945
|
+
* which includes a lineage whose end cause is a claim of its identity.
|
|
732
946
|
*/
|
|
733
947
|
#sessionEndedError(row) {
|
|
734
948
|
const newest = this.#newestGeneration(row);
|
|
735
949
|
if (Number(newest.active) === 1)
|
|
736
950
|
return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
|
|
951
|
+
if (newest.end_cause === "claimed")
|
|
952
|
+
return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
|
|
737
953
|
const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
|
|
738
954
|
if (typeof archived?.archived_at === "number")
|
|
739
955
|
return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
|
|
@@ -1520,7 +1736,7 @@ export class Broker {
|
|
|
1520
1736
|
return this.#readableAgentRows(agents).map(({ id, row }) => {
|
|
1521
1737
|
const workspace = names.get(String(row.workspace_id)) ?? String(row.workspace_id);
|
|
1522
1738
|
const alias = normalizeAlias(String(row.registration_name));
|
|
1523
|
-
return { id, alias, workspace, address:
|
|
1739
|
+
return { id, alias, workspace, address: formatAgentAddress(alias, workspace) };
|
|
1524
1740
|
});
|
|
1525
1741
|
}
|
|
1526
1742
|
/**
|
|
@@ -1547,18 +1763,17 @@ export class Broker {
|
|
|
1547
1763
|
sessionName: validateRegistrationSessionName(requestedName ?? defaultAgentSessionName(agentId)),
|
|
1548
1764
|
};
|
|
1549
1765
|
}
|
|
1550
|
-
#registerAgent(args, identity,
|
|
1766
|
+
#registerAgent(args, identity, location) {
|
|
1551
1767
|
const { agentId, sessionName } = identity;
|
|
1552
1768
|
const registeredAt = this.#now();
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
this.#ensureWorkspaceAndTeam(workspaceId, workspacePath, args.teamId, registeredAt, branch);
|
|
1769
|
+
const workspaceId = workspaceIdentity(location.workspaceRoot);
|
|
1770
|
+
this.#ensureWorkspaceAndTeam(workspaceId, location.workspaceRoot, args.teamId, registeredAt);
|
|
1556
1771
|
const existing = this.#db.prepare("select state from agents where id = ?").get(agentId);
|
|
1557
1772
|
if (existing && existing.state !== "active") {
|
|
1558
1773
|
throw new ProtocolError("conflict", "Only an active agent identity can be reused");
|
|
1559
1774
|
}
|
|
1560
1775
|
this.#assertNameAvailable(workspaceId, sessionName, agentId);
|
|
1561
|
-
this.#write("insert or ignore into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, workspaceId, args.parentId ?? null, registeredAt, args.implementation?.id ?? null, args.implementation?.version ?? null, sessionName, args.ephemeral ? 1 : 0);
|
|
1776
|
+
this.#write("insert or ignore into agents(id, team_id, workspace_id, working_directory, branch, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, workspaceId, location.workingDirectory, location.branch, args.parentId ?? null, registeredAt, args.implementation?.id ?? null, args.implementation?.version ?? null, sessionName, args.ephemeral ? 1 : 0);
|
|
1562
1777
|
return agentId;
|
|
1563
1778
|
}
|
|
1564
1779
|
/**
|
|
@@ -1578,39 +1793,186 @@ export class Broker {
|
|
|
1578
1793
|
}
|
|
1579
1794
|
}
|
|
1580
1795
|
/**
|
|
1581
|
-
*
|
|
1796
|
+
* Locate the directory a registering agent works in.
|
|
1582
1797
|
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* otherwise go unseen forever.
|
|
1589
|
-
* @param workspacePath - Absolute path the port inspects.
|
|
1590
|
-
* @returns - Branch currently checked out at that path, or null when it is not a repository.
|
|
1798
|
+
* Call it before the write transaction opens: both ports may block on a subprocess, and holding the
|
|
1799
|
+
* write lock across them stalls every other connection.
|
|
1800
|
+
* @param workingDirectory - Absolute directory the registering agent works in.
|
|
1801
|
+
* @returns - That directory, the path of the workspace it belongs to, and the branch checked out
|
|
1802
|
+
* there, or a null branch when it is not a repository.
|
|
1591
1803
|
*/
|
|
1592
|
-
#
|
|
1593
|
-
return
|
|
1804
|
+
#locate(workingDirectory) {
|
|
1805
|
+
return {
|
|
1806
|
+
workingDirectory,
|
|
1807
|
+
workspaceRoot: this.#workspaceRootOf(workingDirectory),
|
|
1808
|
+
branch: this.#workspaceBranch(workingDirectory),
|
|
1809
|
+
};
|
|
1594
1810
|
}
|
|
1595
1811
|
/**
|
|
1596
|
-
*
|
|
1812
|
+
* Ask the workspace-root port which directory names a working directory's workspace.
|
|
1597
1813
|
*
|
|
1598
|
-
* A
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1814
|
+
* A port that throws is reported, and the working directory stands in as its own workspace, so a
|
|
1815
|
+
* registration is never refused over a lookup. A worktree registered this way shares its repository's
|
|
1816
|
+
* workspace once a later registration or resume from it finds the repository.
|
|
1817
|
+
* @param workingDirectory - Absolute directory the registering agent works in.
|
|
1818
|
+
* @returns - The directory naming its workspace, resolved.
|
|
1819
|
+
*/
|
|
1820
|
+
#workspaceRootOf(workingDirectory) {
|
|
1821
|
+
try {
|
|
1822
|
+
return resolve(this.#workspaceRoot(workingDirectory));
|
|
1823
|
+
}
|
|
1824
|
+
catch (error) {
|
|
1825
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1826
|
+
this.#reportSafely(`Workspace lookup for ${workingDirectory} failed (${detail}); registering it as its own workspace`);
|
|
1827
|
+
return workingDirectory;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Read the location an identity last registered from.
|
|
1832
|
+
* @param agentId - Identity whose recorded location is read.
|
|
1833
|
+
* @returns - Its working directory and its workspace's path, or nothing once its row is gone.
|
|
1834
|
+
*/
|
|
1835
|
+
#registeredLocation(agentId) {
|
|
1836
|
+
const row = this.#db
|
|
1837
|
+
.prepare("select a.working_directory, w.path from agents a join workspaces w on w.id = a.workspace_id where a.id = ?")
|
|
1838
|
+
.get(agentId);
|
|
1839
|
+
if (!row)
|
|
1840
|
+
return undefined;
|
|
1841
|
+
return { workingDirectory: String(row.working_directory), workspaceRoot: String(row.path) };
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Choose the workspace a resume inside the write transaction follows.
|
|
1845
|
+
*
|
|
1846
|
+
* The location settled before the transaction applies only to the identity, still in the workspace,
|
|
1847
|
+
* that the lookup before the transaction saw. Any other identity, or one moved since, keeps the
|
|
1848
|
+
* workspace it is registered in.
|
|
1849
|
+
* @param agentId - Identity being resumed.
|
|
1850
|
+
* @param registeredAhead - The identity the lookup before the transaction found and the location it
|
|
1851
|
+
* was registered at then, or nothing when that lookup found none.
|
|
1852
|
+
* @param location - Location settled before the transaction opened.
|
|
1853
|
+
* @returns - Path of the workspace to follow.
|
|
1854
|
+
*/
|
|
1855
|
+
#workspaceRootToFollow(agentId, registeredAhead, location) {
|
|
1856
|
+
const registeredNow = this.#registeredLocation(agentId)?.workspaceRoot;
|
|
1857
|
+
const unmovedSinceLookup = registeredAhead?.agentId === agentId && registeredAhead.workspaceRoot === registeredNow;
|
|
1858
|
+
return unmovedSinceLookup || registeredNow === undefined ? location.workspaceRoot : registeredNow;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Settle the workspace a resuming identity belongs to.
|
|
1862
|
+
*
|
|
1863
|
+
* An identity whose workspace is its own working directory may be a stand-in from a lookup that
|
|
1864
|
+
* failed when it registered, which looks exactly like a directory outside any repository or a
|
|
1865
|
+
* repository's own root. The port is asked again for all three; only a different answer moves it.
|
|
1866
|
+
* @param registered - Location the identity last registered from.
|
|
1867
|
+
* @returns - The workspace path the identity belongs to now.
|
|
1868
|
+
*/
|
|
1869
|
+
#confirmedWorkspaceRoot(registered) {
|
|
1870
|
+
if (registered.workspaceRoot !== registered.workingDirectory)
|
|
1871
|
+
return registered.workspaceRoot;
|
|
1872
|
+
return this.#workspaceRootOf(registered.workingDirectory);
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Move a resuming identity into the workspace its directory now resolves to, when it can take its
|
|
1876
|
+
* alias there; otherwise it keeps the workspace it has and the conflict is reported.
|
|
1877
|
+
* @param agent - Identity being resumed.
|
|
1878
|
+
* @param workspaceRoot - Path of the workspace it belongs to now.
|
|
1879
|
+
*/
|
|
1880
|
+
#followWorkspace(agent, workspaceRoot) {
|
|
1881
|
+
try {
|
|
1882
|
+
const workspaceId = workspaceIdentity(workspaceRoot);
|
|
1883
|
+
const current = this.#db.prepare("select workspace_id from agents where id = ?").get(agent.agentId);
|
|
1884
|
+
if (!current || String(current.workspace_id) === workspaceId)
|
|
1885
|
+
return;
|
|
1886
|
+
this.#assertNameAvailable(workspaceId, agent.sessionName, agent.agentId);
|
|
1887
|
+
this.#moveToWorkspace(agent.agentId, workspaceRoot, agent.sessionName);
|
|
1888
|
+
}
|
|
1889
|
+
catch (error) {
|
|
1890
|
+
if (!(error instanceof ProtocolError && error.code === "alias_conflict"))
|
|
1891
|
+
throw error;
|
|
1892
|
+
this.#reportSafely(`Workspace move of ${normalizeAlias(agent.sessionName)} (${agent.agentId}) to ${workspaceRoot} refused (${error.message}); it keeps its current workspace`);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Move an identity into another workspace under the alias it will hold there.
|
|
1897
|
+
*
|
|
1898
|
+
* Every alias it held in the workspace it leaves, its current one included, stays reserved there
|
|
1899
|
+
* as a redirect to it, and its retired aliases, and the alias it held before the move when that
|
|
1900
|
+
* differs from the one it takes, are reserved in the workspace it joins. A reservation the identity
|
|
1901
|
+
* itself holds on the alias it takes is released. Messages it has already sent or received keep the
|
|
1902
|
+
* workspace they were recorded under. The caller has settled that the alias is free in the target.
|
|
1903
|
+
* @param agentId - Identity to move.
|
|
1904
|
+
* @param workspaceRoot - Path naming the target workspace, created when no agent has registered in it yet.
|
|
1905
|
+
* @param sessionName - Name the identity registers under in the target workspace, already validated.
|
|
1906
|
+
*/
|
|
1907
|
+
#moveToWorkspace(agentId, workspaceRoot, sessionName) {
|
|
1908
|
+
const workspaceId = workspaceIdentity(workspaceRoot);
|
|
1909
|
+
const current = this.#db.prepare("select workspace_id, registration_name from agents where id = ?").get(agentId);
|
|
1910
|
+
if (!current || String(current.workspace_id) === workspaceId)
|
|
1911
|
+
return;
|
|
1912
|
+
const previousWorkspaceId = String(current.workspace_id);
|
|
1913
|
+
const previousAlias = normalizeAlias(String(current.registration_name));
|
|
1914
|
+
const nextAlias = normalizeAlias(sessionName);
|
|
1915
|
+
const teamId = workspaceTeamId(workspaceRoot);
|
|
1916
|
+
this.#ensureWorkspaceAndTeam(workspaceId, workspaceRoot, teamId, this.#now());
|
|
1917
|
+
// A single update, so the unique alias index sees only the final workspace and alias.
|
|
1918
|
+
this.#write("update agents set workspace_id = ?, team_id = ?, registration_name = ? where id = ?", workspaceId, teamId, sessionName, agentId);
|
|
1919
|
+
this.#copyAliasHistory(agentId, previousWorkspaceId, workspaceId);
|
|
1920
|
+
this.#reserveAlias(previousWorkspaceId, previousAlias, agentId);
|
|
1921
|
+
if (previousAlias !== nextAlias)
|
|
1922
|
+
this.#reserveAlias(workspaceId, previousAlias, agentId);
|
|
1923
|
+
this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Reserve, in one workspace, every retired alias an identity holds in another.
|
|
1927
|
+
* @param agentId - Identity whose retired aliases are copied.
|
|
1928
|
+
* @param fromWorkspaceId - Workspace the aliases are held in.
|
|
1929
|
+
* @param toWorkspaceId - Workspace they are reserved in as well.
|
|
1930
|
+
*/
|
|
1931
|
+
#copyAliasHistory(agentId, fromWorkspaceId, toWorkspaceId) {
|
|
1932
|
+
const rows = this.#db
|
|
1933
|
+
.prepare("select alias from alias_history where agent_id = ? and workspace_id = ? order by id")
|
|
1934
|
+
.all(agentId, fromWorkspaceId);
|
|
1935
|
+
for (const row of rows)
|
|
1936
|
+
this.#reserveAlias(toWorkspaceId, String(row.alias), agentId);
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Record the branch an identity's working directory has checked out.
|
|
1940
|
+
*
|
|
1941
|
+
* A `null` branch keeps the one already stored: the lookup collapses "not a repository" and "the git
|
|
1942
|
+
* subprocess failed" into the same answer, and a transient failure must not blank out a branch an
|
|
1943
|
+
* earlier registration actually observed.
|
|
1944
|
+
* @param agentId - Identity whose branch is recorded.
|
|
1945
|
+
* @param branch - Branch read before this transaction opened, or null when none was observed.
|
|
1946
|
+
*/
|
|
1947
|
+
#recordBranch(agentId, branch) {
|
|
1948
|
+
this.#write("update agents set branch = coalesce(?, branch) where id = ?", branch, agentId);
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Record that a rejoined identity now works where its new holder does.
|
|
1952
|
+
*
|
|
1953
|
+
* The identity gives up its conversation key when the directory changes: the key names a
|
|
1954
|
+
* conversation in the directory it was bound in, and the holder that displaced it brings its own.
|
|
1955
|
+
* A `null` branch keeps the stored one only while the directory is unchanged.
|
|
1956
|
+
* @param agentId - Identity being rejoined.
|
|
1957
|
+
* @param location - Where the rejoining holder works, read before this transaction opened.
|
|
1958
|
+
*/
|
|
1959
|
+
#rejoinFromLocation(agentId, location) {
|
|
1960
|
+
this.#write("update agents set session_key = case when working_directory = ? then session_key end, branch = coalesce(?, case when working_directory = ? then branch end), working_directory = ? where id = ?", location.workingDirectory, location.branch, location.workingDirectory, location.workingDirectory, agentId);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Record the workspace and team a registration lands in.
|
|
1601
1964
|
* @param workspaceId - Identity derived from the workspace path.
|
|
1602
1965
|
* @param workspacePath - Absolute path the workspace identity was derived from.
|
|
1603
1966
|
* @param teamId - Team the registering agent joins.
|
|
1604
1967
|
* @param registeredAt - Broker-clock timestamp shared with the agent row this registration writes.
|
|
1605
|
-
* @param branch - Branch checked out at that path, read before this transaction opened.
|
|
1606
1968
|
*/
|
|
1607
|
-
#ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt
|
|
1608
|
-
this.#write("insert into workspaces(id, path,
|
|
1969
|
+
#ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt) {
|
|
1970
|
+
this.#write("insert or ignore into workspaces(id, path, created_at) values (?, ?, ?)", workspaceId, workspacePath, registeredAt);
|
|
1609
1971
|
this.#write("insert or ignore into teams(id, workspace_id, created_at) values (?, ?, ?)", teamId, workspaceId, registeredAt);
|
|
1610
1972
|
}
|
|
1611
1973
|
#registerAgentWithoutReservation(args) {
|
|
1612
1974
|
const registeredAt = this.#now();
|
|
1613
|
-
this.#ensureWorkspaceAndTeam(args.workspaceId, args.
|
|
1975
|
+
this.#ensureWorkspaceAndTeam(args.workspaceId, args.location.workspaceRoot, args.teamId, registeredAt);
|
|
1614
1976
|
const existing = this.#db
|
|
1615
1977
|
.prepare("select id, implementation_id from agents where workspace_id = ? and registration_name = ? and state = 'active'")
|
|
1616
1978
|
.get(args.workspaceId, args.sessionName);
|
|
@@ -1622,11 +1984,12 @@ export class Broker {
|
|
|
1622
1984
|
this.#assertNameAvailable(args.workspaceId, args.sessionName, agentId);
|
|
1623
1985
|
this.#assertHolderDetached(agentId, args.sessionName);
|
|
1624
1986
|
this.#displaceHolder(agentId);
|
|
1987
|
+
this.#rejoinFromLocation(agentId, args.location);
|
|
1625
1988
|
return agentId;
|
|
1626
1989
|
}
|
|
1627
1990
|
this.#assertNameAvailable(args.workspaceId, args.sessionName);
|
|
1628
1991
|
const agentId = mintAgentId(issueOpaqueToken("agent"));
|
|
1629
|
-
this.#write("insert into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, null, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, args.workspaceId, registeredAt, args.implementation.id, args.implementation.version ?? null, args.sessionName, args.ephemeral ? 1 : 0);
|
|
1992
|
+
this.#write("insert into agents(id, team_id, workspace_id, working_directory, branch, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, ?, null, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, args.workspaceId, args.location.workingDirectory, args.location.branch, registeredAt, args.implementation.id, args.implementation.version ?? null, args.sessionName, args.ephemeral ? 1 : 0);
|
|
1630
1993
|
return agentId;
|
|
1631
1994
|
}
|
|
1632
1995
|
/**
|
|
@@ -1644,15 +2007,32 @@ export class Broker {
|
|
|
1644
2007
|
.get(holderId, this.#now());
|
|
1645
2008
|
if (!live)
|
|
1646
2009
|
return;
|
|
1647
|
-
|
|
1648
|
-
const address = this.#agentAddress(holderId, this.workspaceNames());
|
|
1649
|
-
const implementation = typeof holder?.implementation_id === "string" ? `${holder.implementation_id}, ` : "";
|
|
1650
|
-
throw new ProtocolError("alias_conflict", `Session name ${sessionName} is held by ${address} (${implementation}${holderId}), whose attachment is live; choose another name, or end that session first`);
|
|
2010
|
+
throw new ProtocolError("alias_conflict", `Session name ${sessionName} is held by ${this.#describeAgent(holderId, this.workspaceNames())}, whose attachment is live; choose another name, or end that session first`);
|
|
1651
2011
|
}
|
|
1652
|
-
|
|
2012
|
+
/**
|
|
2013
|
+
* Find the identity a conversation registering from a directory resumes.
|
|
2014
|
+
*
|
|
2015
|
+
* The conversation's own key is looked up first. Only when it owns nothing is the key of the
|
|
2016
|
+
* conversation it continues tried.
|
|
2017
|
+
* @param workingDirectory - Absolute directory the conversation registers from.
|
|
2018
|
+
* @param keys - Implementation and the conversation's keys; no key means nothing is resumable.
|
|
2019
|
+
* @returns - The identity and whether the continued key, rather than the conversation's own, found it.
|
|
2020
|
+
*/
|
|
2021
|
+
#resumableIdentity(workingDirectory, keys) {
|
|
2022
|
+
if (keys.sessionKey === undefined)
|
|
2023
|
+
return undefined;
|
|
2024
|
+
const owned = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.sessionKey);
|
|
2025
|
+
if (owned)
|
|
2026
|
+
return { agent: owned, byContinuedKey: false };
|
|
2027
|
+
if (keys.continuesSessionKey === undefined)
|
|
2028
|
+
return undefined;
|
|
2029
|
+
const continued = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.continuesSessionKey);
|
|
2030
|
+
return continued && { agent: continued, byContinuedKey: true };
|
|
2031
|
+
}
|
|
2032
|
+
#agentForSessionKey(workingDirectory, implementationId, sessionKey) {
|
|
1653
2033
|
const row = this.#db
|
|
1654
|
-
.prepare("select id, registration_name, registration_ephemeral from agents where
|
|
1655
|
-
.get(
|
|
2034
|
+
.prepare("select id, registration_name, registration_ephemeral from agents where working_directory = ? and implementation_id = ? and session_key = ? and state = 'active'")
|
|
2035
|
+
.get(workingDirectory, implementationId, sessionKey);
|
|
1656
2036
|
if (!row)
|
|
1657
2037
|
return undefined;
|
|
1658
2038
|
return {
|
|
@@ -1683,14 +2063,14 @@ export class Broker {
|
|
|
1683
2063
|
}
|
|
1684
2064
|
#resolveSessionKeyAtAttach(reserved, sessionKey) {
|
|
1685
2065
|
const reservedAgent = this.#db
|
|
1686
|
-
.prepare("select
|
|
2066
|
+
.prepare("select working_directory, implementation_id, session_key from agents where id = ?")
|
|
1687
2067
|
.get(reserved);
|
|
1688
2068
|
if (!reservedAgent)
|
|
1689
2069
|
throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
|
|
1690
2070
|
if (typeof reservedAgent.implementation_id !== "string") {
|
|
1691
2071
|
throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
|
|
1692
2072
|
}
|
|
1693
|
-
const owner = this.#agentForSessionKey(String(reservedAgent.
|
|
2073
|
+
const owner = this.#agentForSessionKey(String(reservedAgent.working_directory), reservedAgent.implementation_id, sessionKey);
|
|
1694
2074
|
if (!owner || owner.agentId === reserved) {
|
|
1695
2075
|
this.#bindSessionKey(reserved, sessionKey, true);
|
|
1696
2076
|
return reserved;
|
|
@@ -1828,19 +2208,12 @@ export class Broker {
|
|
|
1828
2208
|
return Boolean(row);
|
|
1829
2209
|
}
|
|
1830
2210
|
#assertNameAvailable(workspaceId, sessionName, owner) {
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
.get(workspaceId, alias);
|
|
1838
|
-
if (registration && registration.id !== owner) {
|
|
1839
|
-
throw new ProtocolError("alias_conflict", "Agent alias is already in use in this workspace");
|
|
1840
|
-
}
|
|
1841
|
-
if (historical && historical.agent_id !== owner) {
|
|
1842
|
-
throw new ProtocolError("alias_conflict", "Agent alias is a retired name that stays reserved in this workspace");
|
|
1843
|
-
}
|
|
2211
|
+
const holder = this.#aliasHolder(workspaceId, sessionName, owner);
|
|
2212
|
+
if (!holder)
|
|
2213
|
+
return;
|
|
2214
|
+
throw new ProtocolError("alias_conflict", holder.retired
|
|
2215
|
+
? "Agent alias is a retired name that stays reserved in this workspace"
|
|
2216
|
+
: "Agent alias is already in use in this workspace");
|
|
1844
2217
|
}
|
|
1845
2218
|
#pairSendCount(from, to) {
|
|
1846
2219
|
const row = this.#db
|
|
@@ -2119,10 +2492,11 @@ export class Broker {
|
|
|
2119
2492
|
if (current)
|
|
2120
2493
|
return readStoredAgentId(current.id, "agents.id");
|
|
2121
2494
|
const historical = this.#db
|
|
2122
|
-
.prepare("select a.
|
|
2495
|
+
.prepare("select a.id from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
|
|
2123
2496
|
.get(targetWorkspace, normalizeAlias(address.sessionName));
|
|
2124
|
-
if (
|
|
2125
|
-
|
|
2497
|
+
if (historical) {
|
|
2498
|
+
const current = this.#agentAddress(readStoredAgentId(historical.id, "alias_history.agent_id"), this.workspaceNames());
|
|
2499
|
+
throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${current}; try that address instead`);
|
|
2126
2500
|
}
|
|
2127
2501
|
throw new ProtocolError("unknown_recipient", "Unknown recipient");
|
|
2128
2502
|
}
|
|
@@ -2142,7 +2516,7 @@ export class Broker {
|
|
|
2142
2516
|
if (typeof alias !== "string" || alias === "")
|
|
2143
2517
|
return agentId;
|
|
2144
2518
|
const workspace = workspaces.get(String(row.workspace_id)) ?? String(row.workspace_id);
|
|
2145
|
-
return
|
|
2519
|
+
return formatAgentAddress(alias, workspace);
|
|
2146
2520
|
}
|
|
2147
2521
|
#workspaceForName(name, names) {
|
|
2148
2522
|
for (const [workspaceId, workspaceName] of names) {
|
|
@@ -2273,6 +2647,14 @@ function mutatedTable(sql) {
|
|
|
2273
2647
|
throw new Error(`Broker write statement names no table: ${sql}`);
|
|
2274
2648
|
return match[1];
|
|
2275
2649
|
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Read a nullable text column.
|
|
2652
|
+
* @param value - Column value as the driver returned it.
|
|
2653
|
+
* @returns - The text, or null for any other value.
|
|
2654
|
+
*/
|
|
2655
|
+
function textOrNull(value) {
|
|
2656
|
+
return typeof value === "string" ? value : null;
|
|
2657
|
+
}
|
|
2276
2658
|
function contentType(body) {
|
|
2277
2659
|
return typeof body === "string" ? "text" : "json";
|
|
2278
2660
|
}
|