@haywoodmarx/agent-pager 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +112 -24
- package/dist/src/broker/broker.js +604 -132
- package/dist/src/broker/operator-projection.d.ts +22 -0
- package/dist/src/broker/operator-projection.js +43 -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 +20 -8
- 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 +2 -2
- package/dist/src/index.js +2 -2
- 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/commands.d.ts +2 -0
- package/dist/src/operator/commands.js +57 -21
- package/dist/src/operator/protocol.d.ts +34 -2
- package/dist/src/operator/protocol.js +9 -0
- 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/operator/transport.js +10 -5
- package/dist/src/operator/updates.js +7 -1
- package/dist/src/operator/validation.js +35 -1
- package/dist/src/shared/versions.d.ts +18 -0
- package/dist/src/shared/versions.js +26 -0
- package/dist/src/tui/agent-tree.d.ts +8 -5
- package/dist/src/tui/agent-tree.js +195 -22
- package/dist/src/tui/app.js +6 -2
- package/dist/src/tui/message-views.js +4 -3
- package/dist/src/tui/model.d.ts +25 -1
- package/dist/src/tui/model.js +33 -9
- package/dist/src/tui/presentation.d.ts +11 -2
- package/dist/src/tui/presentation.js +26 -4
- 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,12 +1,12 @@
|
|
|
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";
|
|
9
|
-
import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.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
|
+
import { assertDurableOperatorId, encodePurgeTargets, samePurgeTargets, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.js";
|
|
10
10
|
const notificationDisplay = {
|
|
11
11
|
delivered: { text: "notify delivered", tone: "ok" },
|
|
12
12
|
shed: { text: "notify shed — budget exceeded", tone: "warn" },
|
|
@@ -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 };
|
|
@@ -629,15 +842,20 @@ export class Broker {
|
|
|
629
842
|
const settled = this.#db
|
|
630
843
|
.prepare("select id from messages where state in ('acknowledged', 'expired', 'dead_lettered', 'failed') and created_at <= ?")
|
|
631
844
|
.all(cutoff);
|
|
632
|
-
for (const row of settled)
|
|
633
|
-
|
|
634
|
-
this.#write("delete from message_events where message_id = ?", messageId);
|
|
635
|
-
this.#write("delete from claims where message_id = ?", messageId);
|
|
636
|
-
this.#write("delete from idempotency where message_id = ?", messageId);
|
|
637
|
-
this.#write("delete from messages where id = ?", messageId);
|
|
638
|
-
}
|
|
845
|
+
for (const row of settled)
|
|
846
|
+
this.#deleteMessage(String(row.id));
|
|
639
847
|
});
|
|
640
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Delete one message and every row that belongs to it.
|
|
851
|
+
* @param messageId - Message whose events, claims and idempotency keys go with it.
|
|
852
|
+
*/
|
|
853
|
+
#deleteMessage(messageId) {
|
|
854
|
+
this.#write("delete from message_events where message_id = ?", messageId);
|
|
855
|
+
this.#write("delete from claims where message_id = ?", messageId);
|
|
856
|
+
this.#write("delete from idempotency where message_id = ?", messageId);
|
|
857
|
+
this.#write("delete from messages where id = ?", messageId);
|
|
858
|
+
}
|
|
641
859
|
/**
|
|
642
860
|
* Resume an MCP attachment while rotating a lapsed or prior-epoch session generation.
|
|
643
861
|
* @param request - Session, fencing proof, and last-seen generation the caller resumes with.
|
|
@@ -728,12 +946,17 @@ export class Broker {
|
|
|
728
946
|
* Name why a known session no longer resolves.
|
|
729
947
|
* @param row - The session row the caller presented, inactive or past its lease.
|
|
730
948
|
* @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
|
|
949
|
+
* operator archive or purge or by a newer attachment of the same identity and channel, otherwise
|
|
950
|
+
* `expired`, which includes a lineage whose end cause is a claim of its identity.
|
|
732
951
|
*/
|
|
733
952
|
#sessionEndedError(row) {
|
|
734
953
|
const newest = this.#newestGeneration(row);
|
|
735
954
|
if (Number(newest.active) === 1)
|
|
736
955
|
return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
|
|
956
|
+
if (newest.end_cause === "claimed")
|
|
957
|
+
return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
|
|
958
|
+
if (newest.end_cause === "purged")
|
|
959
|
+
return new ProtocolError("ended", "MCP session ended because an operator purged this agent");
|
|
737
960
|
const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(newest.agent_id));
|
|
738
961
|
if (typeof archived?.archived_at === "number")
|
|
739
962
|
return new ProtocolError("ended", "MCP session ended because an operator archived this agent");
|
|
@@ -1317,12 +1540,15 @@ export class Broker {
|
|
|
1317
1540
|
}
|
|
1318
1541
|
/**
|
|
1319
1542
|
* Append an operational event recording the outcome of a delivery notification attempt.
|
|
1320
|
-
* @param messageId - Message the notification outcome is recorded against
|
|
1543
|
+
* @param messageId - Message the notification outcome is recorded against; an outcome for a
|
|
1544
|
+
* message deleted while its notification was in flight is dropped.
|
|
1321
1545
|
* @param outcome - Observed notification result recorded for diagnostics.
|
|
1322
1546
|
*/
|
|
1323
1547
|
recordNotification(messageId, outcome) {
|
|
1324
1548
|
const display = notificationDisplay[outcome];
|
|
1325
1549
|
this.#transaction(() => {
|
|
1550
|
+
if (this.#db.prepare("select 1 as present from messages where id = ?").get(messageId) === undefined)
|
|
1551
|
+
return;
|
|
1326
1552
|
this.#appendEvent(messageId, "notify", `notify_${outcome}`, display.text, display.tone);
|
|
1327
1553
|
});
|
|
1328
1554
|
}
|
|
@@ -1444,6 +1670,86 @@ export class Broker {
|
|
|
1444
1670
|
});
|
|
1445
1671
|
return acceptance;
|
|
1446
1672
|
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Accept one operator purge request, deleting the named identities and their records.
|
|
1675
|
+
*
|
|
1676
|
+
* A message is deleted with its events and claims only when neither end is an identity that
|
|
1677
|
+
* outlives the purge. A message exchanged with one that does stays in that identity's history,
|
|
1678
|
+
* naming the purged end by its raw id, and is dead-lettered if it was still waiting in a purged
|
|
1679
|
+
* inbox. Operator command records keep the ids they targeted, so a retried command is still
|
|
1680
|
+
* answered from its record, and the identity's sessions are kept ended, so a process still
|
|
1681
|
+
* attached to it is told it was ended. Claims a purged identity made on a kept message stay as
|
|
1682
|
+
* that message's delivery attempts, and operator stream events already published about the
|
|
1683
|
+
* identity stay in the replay journal until it trims them. Every other row naming a purged identity
|
|
1684
|
+
* is deleted.
|
|
1685
|
+
* Workspace rows are kept even when no identity remains in them, because previous addresses
|
|
1686
|
+
* into a workspace and the short names of other workspaces are resolved from them.
|
|
1687
|
+
* @param commandId - Correlates retries of the same operator request.
|
|
1688
|
+
* @param agentIds - Distinct agents the operator asked to purge, in any lifecycle state.
|
|
1689
|
+
* @returns - Durable acceptance, reporting whether this call created it.
|
|
1690
|
+
*/
|
|
1691
|
+
acceptOperatorPurge(commandId, agentIds) {
|
|
1692
|
+
assertDurableOperatorId(commandId, "command");
|
|
1693
|
+
const targetId = encodePurgeTargets(agentIds);
|
|
1694
|
+
return this.#transaction(() => {
|
|
1695
|
+
const prior = this.operatorCommand(commandId);
|
|
1696
|
+
if (prior) {
|
|
1697
|
+
if (prior.type !== "purge_agents" || !samePurgeTargets(prior.targetIds, agentIds)) {
|
|
1698
|
+
throw new ProtocolError("conflict", "Operator command already exists");
|
|
1699
|
+
}
|
|
1700
|
+
return { status: "existing", record: prior };
|
|
1701
|
+
}
|
|
1702
|
+
const targets = new Set(agentIds);
|
|
1703
|
+
if (agentIds.length === 0 || targets.size !== agentIds.length)
|
|
1704
|
+
throw new ProtocolError("validation_failed", "Purge targets must be distinct and non-empty");
|
|
1705
|
+
const present = this.#db.prepare("select 1 as present from agents where id = ?");
|
|
1706
|
+
for (const agentId of targets) {
|
|
1707
|
+
if (present.get(agentId) === undefined)
|
|
1708
|
+
throw new ProtocolError("validation_failed", "Unknown agent");
|
|
1709
|
+
}
|
|
1710
|
+
const survivors = new Map();
|
|
1711
|
+
const outlives = (agentId) => {
|
|
1712
|
+
if (typeof agentId !== "string" || targets.has(agentId))
|
|
1713
|
+
return false;
|
|
1714
|
+
let known = survivors.get(agentId);
|
|
1715
|
+
if (known === undefined) {
|
|
1716
|
+
known = present.get(agentId) !== undefined;
|
|
1717
|
+
survivors.set(agentId, known);
|
|
1718
|
+
}
|
|
1719
|
+
return known;
|
|
1720
|
+
};
|
|
1721
|
+
const involving = this.#db.prepare("select id, sender_id, recipient_id, state from messages where sender_id = ? or recipient_id = ? order by sequence");
|
|
1722
|
+
for (const agentId of targets) {
|
|
1723
|
+
const waiting = [];
|
|
1724
|
+
for (const row of involving.all(agentId, agentId)) {
|
|
1725
|
+
if (!outlives(row.sender_id) && !outlives(row.recipient_id))
|
|
1726
|
+
this.#deleteMessage(String(row.id));
|
|
1727
|
+
else if (row.recipient_id === agentId && (row.state === "queued" || row.state === "claimed"))
|
|
1728
|
+
waiting.push(row);
|
|
1729
|
+
}
|
|
1730
|
+
this.#deadLetter(waiting, "dead-lettered — recipient was purged");
|
|
1731
|
+
}
|
|
1732
|
+
for (const agentId of targets) {
|
|
1733
|
+
this.#write("delete from idempotency where agent_id = ? or recipient_id = ?", agentId, agentId);
|
|
1734
|
+
this.#write("delete from alias_history where agent_id = ?", agentId);
|
|
1735
|
+
// Kept, ended, so a process still attached is told it was ended rather than that its session
|
|
1736
|
+
// lapsed, which a connector answers by registering again under the same alias. A session a
|
|
1737
|
+
// claim already ended keeps that cause, which tells its process to register again.
|
|
1738
|
+
this.#write("update sessions set active = 0, end_cause = coalesce(end_cause, 'purged') where agent_id = ?", agentId);
|
|
1739
|
+
this.#write("delete from reservations where agent_id = ?", agentId);
|
|
1740
|
+
this.#write("delete from wakes where agent_id = ?", agentId);
|
|
1741
|
+
this.#write("delete from operator_ping_audits where target_agent_id = ?", agentId);
|
|
1742
|
+
this.#write("update agents set parent_id = null where parent_id = ?", agentId);
|
|
1743
|
+
this.#write("delete from agents where id = ?", agentId);
|
|
1744
|
+
}
|
|
1745
|
+
const acceptedAt = this.#now();
|
|
1746
|
+
this.#write("insert into operator_commands(command_id, type, target_id, accepted_at, control_id) values (?, 'purge_agents', ?, ?, null)", commandId, targetId, acceptedAt);
|
|
1747
|
+
return {
|
|
1748
|
+
status: "created",
|
|
1749
|
+
record: { type: "purge_agents", commandId, targetIds: [...agentIds], acceptedAt },
|
|
1750
|
+
};
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1447
1753
|
/**
|
|
1448
1754
|
* Returns the broker snapshot, optionally scoped to one workspace.
|
|
1449
1755
|
*
|
|
@@ -1520,7 +1826,7 @@ export class Broker {
|
|
|
1520
1826
|
return this.#readableAgentRows(agents).map(({ id, row }) => {
|
|
1521
1827
|
const workspace = names.get(String(row.workspace_id)) ?? String(row.workspace_id);
|
|
1522
1828
|
const alias = normalizeAlias(String(row.registration_name));
|
|
1523
|
-
return { id, alias, workspace, address:
|
|
1829
|
+
return { id, alias, workspace, address: formatAgentAddress(alias, workspace) };
|
|
1524
1830
|
});
|
|
1525
1831
|
}
|
|
1526
1832
|
/**
|
|
@@ -1547,18 +1853,17 @@ export class Broker {
|
|
|
1547
1853
|
sessionName: validateRegistrationSessionName(requestedName ?? defaultAgentSessionName(agentId)),
|
|
1548
1854
|
};
|
|
1549
1855
|
}
|
|
1550
|
-
#registerAgent(args, identity,
|
|
1856
|
+
#registerAgent(args, identity, location) {
|
|
1551
1857
|
const { agentId, sessionName } = identity;
|
|
1552
1858
|
const registeredAt = this.#now();
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
this.#ensureWorkspaceAndTeam(workspaceId, workspacePath, args.teamId, registeredAt, branch);
|
|
1859
|
+
const workspaceId = workspaceIdentity(location.workspaceRoot);
|
|
1860
|
+
this.#ensureWorkspaceAndTeam(workspaceId, location.workspaceRoot, args.teamId, registeredAt);
|
|
1556
1861
|
const existing = this.#db.prepare("select state from agents where id = ?").get(agentId);
|
|
1557
1862
|
if (existing && existing.state !== "active") {
|
|
1558
1863
|
throw new ProtocolError("conflict", "Only an active agent identity can be reused");
|
|
1559
1864
|
}
|
|
1560
1865
|
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);
|
|
1866
|
+
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
1867
|
return agentId;
|
|
1563
1868
|
}
|
|
1564
1869
|
/**
|
|
@@ -1578,39 +1883,186 @@ export class Broker {
|
|
|
1578
1883
|
}
|
|
1579
1884
|
}
|
|
1580
1885
|
/**
|
|
1581
|
-
*
|
|
1886
|
+
* Locate the directory a registering agent works in.
|
|
1887
|
+
*
|
|
1888
|
+
* Call it before the write transaction opens: both ports may block on a subprocess, and holding the
|
|
1889
|
+
* write lock across them stalls every other connection.
|
|
1890
|
+
* @param workingDirectory - Absolute directory the registering agent works in.
|
|
1891
|
+
* @returns - That directory, the path of the workspace it belongs to, and the branch checked out
|
|
1892
|
+
* there, or a null branch when it is not a repository.
|
|
1893
|
+
*/
|
|
1894
|
+
#locate(workingDirectory) {
|
|
1895
|
+
return {
|
|
1896
|
+
workingDirectory,
|
|
1897
|
+
workspaceRoot: this.#workspaceRootOf(workingDirectory),
|
|
1898
|
+
branch: this.#workspaceBranch(workingDirectory),
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Ask the workspace-root port which directory names a working directory's workspace.
|
|
1582
1903
|
*
|
|
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.
|
|
1904
|
+
* A port that throws is reported, and the working directory stands in as its own workspace, so a
|
|
1905
|
+
* registration is never refused over a lookup. A worktree registered this way shares its repository's
|
|
1906
|
+
* workspace once a later registration or resume from it finds the repository.
|
|
1907
|
+
* @param workingDirectory - Absolute directory the registering agent works in.
|
|
1908
|
+
* @returns - The directory naming its workspace, resolved.
|
|
1591
1909
|
*/
|
|
1592
|
-
#
|
|
1593
|
-
|
|
1910
|
+
#workspaceRootOf(workingDirectory) {
|
|
1911
|
+
try {
|
|
1912
|
+
return resolve(this.#workspaceRoot(workingDirectory));
|
|
1913
|
+
}
|
|
1914
|
+
catch (error) {
|
|
1915
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1916
|
+
this.#reportSafely(`Workspace lookup for ${workingDirectory} failed (${detail}); registering it as its own workspace`);
|
|
1917
|
+
return workingDirectory;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Read the location an identity last registered from.
|
|
1922
|
+
* @param agentId - Identity whose recorded location is read.
|
|
1923
|
+
* @returns - Its working directory and its workspace's path, or nothing once its row is gone.
|
|
1924
|
+
*/
|
|
1925
|
+
#registeredLocation(agentId) {
|
|
1926
|
+
const row = this.#db
|
|
1927
|
+
.prepare("select a.working_directory, w.path from agents a join workspaces w on w.id = a.workspace_id where a.id = ?")
|
|
1928
|
+
.get(agentId);
|
|
1929
|
+
if (!row)
|
|
1930
|
+
return undefined;
|
|
1931
|
+
return { workingDirectory: String(row.working_directory), workspaceRoot: String(row.path) };
|
|
1594
1932
|
}
|
|
1595
1933
|
/**
|
|
1596
|
-
*
|
|
1934
|
+
* Choose the workspace a resume inside the write transaction follows.
|
|
1935
|
+
*
|
|
1936
|
+
* The location settled before the transaction applies only to the identity, still in the workspace,
|
|
1937
|
+
* that the lookup before the transaction saw. Any other identity, or one moved since, keeps the
|
|
1938
|
+
* workspace it is registered in.
|
|
1939
|
+
* @param agentId - Identity being resumed.
|
|
1940
|
+
* @param registeredAhead - The identity the lookup before the transaction found and the location it
|
|
1941
|
+
* was registered at then, or nothing when that lookup found none.
|
|
1942
|
+
* @param location - Location settled before the transaction opened.
|
|
1943
|
+
* @returns - Path of the workspace to follow.
|
|
1944
|
+
*/
|
|
1945
|
+
#workspaceRootToFollow(agentId, registeredAhead, location) {
|
|
1946
|
+
const registeredNow = this.#registeredLocation(agentId)?.workspaceRoot;
|
|
1947
|
+
const unmovedSinceLookup = registeredAhead?.agentId === agentId && registeredAhead.workspaceRoot === registeredNow;
|
|
1948
|
+
return unmovedSinceLookup || registeredNow === undefined ? location.workspaceRoot : registeredNow;
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Settle the workspace a resuming identity belongs to.
|
|
1597
1952
|
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1953
|
+
* An identity whose workspace is its own working directory may be a stand-in from a lookup that
|
|
1954
|
+
* failed when it registered, which looks exactly like a directory outside any repository or a
|
|
1955
|
+
* repository's own root. The port is asked again for all three; only a different answer moves it.
|
|
1956
|
+
* @param registered - Location the identity last registered from.
|
|
1957
|
+
* @returns - The workspace path the identity belongs to now.
|
|
1958
|
+
*/
|
|
1959
|
+
#confirmedWorkspaceRoot(registered) {
|
|
1960
|
+
if (registered.workspaceRoot !== registered.workingDirectory)
|
|
1961
|
+
return registered.workspaceRoot;
|
|
1962
|
+
return this.#workspaceRootOf(registered.workingDirectory);
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Move a resuming identity into the workspace its directory now resolves to, when it can take its
|
|
1966
|
+
* alias there; otherwise it keeps the workspace it has and the conflict is reported.
|
|
1967
|
+
* @param agent - Identity being resumed.
|
|
1968
|
+
* @param workspaceRoot - Path of the workspace it belongs to now.
|
|
1969
|
+
*/
|
|
1970
|
+
#followWorkspace(agent, workspaceRoot) {
|
|
1971
|
+
try {
|
|
1972
|
+
const workspaceId = workspaceIdentity(workspaceRoot);
|
|
1973
|
+
const current = this.#db.prepare("select workspace_id from agents where id = ?").get(agent.agentId);
|
|
1974
|
+
if (!current || String(current.workspace_id) === workspaceId)
|
|
1975
|
+
return;
|
|
1976
|
+
this.#assertNameAvailable(workspaceId, agent.sessionName, agent.agentId);
|
|
1977
|
+
this.#moveToWorkspace(agent.agentId, workspaceRoot, agent.sessionName);
|
|
1978
|
+
}
|
|
1979
|
+
catch (error) {
|
|
1980
|
+
if (!(error instanceof ProtocolError && error.code === "alias_conflict"))
|
|
1981
|
+
throw error;
|
|
1982
|
+
this.#reportSafely(`Workspace move of ${normalizeAlias(agent.sessionName)} (${agent.agentId}) to ${workspaceRoot} refused (${error.message}); it keeps its current workspace`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Move an identity into another workspace under the alias it will hold there.
|
|
1987
|
+
*
|
|
1988
|
+
* Every alias it held in the workspace it leaves, its current one included, stays reserved there
|
|
1989
|
+
* as a redirect to it, and its retired aliases, and the alias it held before the move when that
|
|
1990
|
+
* differs from the one it takes, are reserved in the workspace it joins. A reservation the identity
|
|
1991
|
+
* itself holds on the alias it takes is released. Messages it has already sent or received keep the
|
|
1992
|
+
* workspace they were recorded under. The caller has settled that the alias is free in the target.
|
|
1993
|
+
* @param agentId - Identity to move.
|
|
1994
|
+
* @param workspaceRoot - Path naming the target workspace, created when no agent has registered in it yet.
|
|
1995
|
+
* @param sessionName - Name the identity registers under in the target workspace, already validated.
|
|
1996
|
+
*/
|
|
1997
|
+
#moveToWorkspace(agentId, workspaceRoot, sessionName) {
|
|
1998
|
+
const workspaceId = workspaceIdentity(workspaceRoot);
|
|
1999
|
+
const current = this.#db.prepare("select workspace_id, registration_name from agents where id = ?").get(agentId);
|
|
2000
|
+
if (!current || String(current.workspace_id) === workspaceId)
|
|
2001
|
+
return;
|
|
2002
|
+
const previousWorkspaceId = String(current.workspace_id);
|
|
2003
|
+
const previousAlias = normalizeAlias(String(current.registration_name));
|
|
2004
|
+
const nextAlias = normalizeAlias(sessionName);
|
|
2005
|
+
const teamId = workspaceTeamId(workspaceRoot);
|
|
2006
|
+
this.#ensureWorkspaceAndTeam(workspaceId, workspaceRoot, teamId, this.#now());
|
|
2007
|
+
// A single update, so the unique alias index sees only the final workspace and alias.
|
|
2008
|
+
this.#write("update agents set workspace_id = ?, team_id = ?, registration_name = ? where id = ?", workspaceId, teamId, sessionName, agentId);
|
|
2009
|
+
this.#copyAliasHistory(agentId, previousWorkspaceId, workspaceId);
|
|
2010
|
+
this.#reserveAlias(previousWorkspaceId, previousAlias, agentId);
|
|
2011
|
+
if (previousAlias !== nextAlias)
|
|
2012
|
+
this.#reserveAlias(workspaceId, previousAlias, agentId);
|
|
2013
|
+
this.#releaseOwnReservation(agentId, workspaceId, nextAlias);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Reserve, in one workspace, every retired alias an identity holds in another.
|
|
2017
|
+
* @param agentId - Identity whose retired aliases are copied.
|
|
2018
|
+
* @param fromWorkspaceId - Workspace the aliases are held in.
|
|
2019
|
+
* @param toWorkspaceId - Workspace they are reserved in as well.
|
|
2020
|
+
*/
|
|
2021
|
+
#copyAliasHistory(agentId, fromWorkspaceId, toWorkspaceId) {
|
|
2022
|
+
const rows = this.#db
|
|
2023
|
+
.prepare("select alias from alias_history where agent_id = ? and workspace_id = ? order by id")
|
|
2024
|
+
.all(agentId, fromWorkspaceId);
|
|
2025
|
+
for (const row of rows)
|
|
2026
|
+
this.#reserveAlias(toWorkspaceId, String(row.alias), agentId);
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Record the branch an identity's working directory has checked out.
|
|
2030
|
+
*
|
|
2031
|
+
* A `null` branch keeps the one already stored: the lookup collapses "not a repository" and "the git
|
|
2032
|
+
* subprocess failed" into the same answer, and a transient failure must not blank out a branch an
|
|
2033
|
+
* earlier registration actually observed.
|
|
2034
|
+
* @param agentId - Identity whose branch is recorded.
|
|
2035
|
+
* @param branch - Branch read before this transaction opened, or null when none was observed.
|
|
2036
|
+
*/
|
|
2037
|
+
#recordBranch(agentId, branch) {
|
|
2038
|
+
this.#write("update agents set branch = coalesce(?, branch) where id = ?", branch, agentId);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Record that a rejoined identity now works where its new holder does.
|
|
2042
|
+
*
|
|
2043
|
+
* The identity gives up its conversation key when the directory changes: the key names a
|
|
2044
|
+
* conversation in the directory it was bound in, and the holder that displaced it brings its own.
|
|
2045
|
+
* A `null` branch keeps the stored one only while the directory is unchanged.
|
|
2046
|
+
* @param agentId - Identity being rejoined.
|
|
2047
|
+
* @param location - Where the rejoining holder works, read before this transaction opened.
|
|
2048
|
+
*/
|
|
2049
|
+
#rejoinFromLocation(agentId, location) {
|
|
2050
|
+
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);
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Record the workspace and team a registration lands in.
|
|
1601
2054
|
* @param workspaceId - Identity derived from the workspace path.
|
|
1602
2055
|
* @param workspacePath - Absolute path the workspace identity was derived from.
|
|
1603
2056
|
* @param teamId - Team the registering agent joins.
|
|
1604
2057
|
* @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
2058
|
*/
|
|
1607
|
-
#ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt
|
|
1608
|
-
this.#write("insert into workspaces(id, path,
|
|
2059
|
+
#ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt) {
|
|
2060
|
+
this.#write("insert or ignore into workspaces(id, path, created_at) values (?, ?, ?)", workspaceId, workspacePath, registeredAt);
|
|
1609
2061
|
this.#write("insert or ignore into teams(id, workspace_id, created_at) values (?, ?, ?)", teamId, workspaceId, registeredAt);
|
|
1610
2062
|
}
|
|
1611
2063
|
#registerAgentWithoutReservation(args) {
|
|
1612
2064
|
const registeredAt = this.#now();
|
|
1613
|
-
this.#ensureWorkspaceAndTeam(args.workspaceId, args.
|
|
2065
|
+
this.#ensureWorkspaceAndTeam(args.workspaceId, args.location.workspaceRoot, args.teamId, registeredAt);
|
|
1614
2066
|
const existing = this.#db
|
|
1615
2067
|
.prepare("select id, implementation_id from agents where workspace_id = ? and registration_name = ? and state = 'active'")
|
|
1616
2068
|
.get(args.workspaceId, args.sessionName);
|
|
@@ -1622,11 +2074,12 @@ export class Broker {
|
|
|
1622
2074
|
this.#assertNameAvailable(args.workspaceId, args.sessionName, agentId);
|
|
1623
2075
|
this.#assertHolderDetached(agentId, args.sessionName);
|
|
1624
2076
|
this.#displaceHolder(agentId);
|
|
2077
|
+
this.#rejoinFromLocation(agentId, args.location);
|
|
1625
2078
|
return agentId;
|
|
1626
2079
|
}
|
|
1627
2080
|
this.#assertNameAvailable(args.workspaceId, args.sessionName);
|
|
1628
2081
|
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);
|
|
2082
|
+
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
2083
|
return agentId;
|
|
1631
2084
|
}
|
|
1632
2085
|
/**
|
|
@@ -1644,15 +2097,32 @@ export class Broker {
|
|
|
1644
2097
|
.get(holderId, this.#now());
|
|
1645
2098
|
if (!live)
|
|
1646
2099
|
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`);
|
|
2100
|
+
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
2101
|
}
|
|
1652
|
-
|
|
2102
|
+
/**
|
|
2103
|
+
* Find the identity a conversation registering from a directory resumes.
|
|
2104
|
+
*
|
|
2105
|
+
* The conversation's own key is looked up first. Only when it owns nothing is the key of the
|
|
2106
|
+
* conversation it continues tried.
|
|
2107
|
+
* @param workingDirectory - Absolute directory the conversation registers from.
|
|
2108
|
+
* @param keys - Implementation and the conversation's keys; no key means nothing is resumable.
|
|
2109
|
+
* @returns - The identity and whether the continued key, rather than the conversation's own, found it.
|
|
2110
|
+
*/
|
|
2111
|
+
#resumableIdentity(workingDirectory, keys) {
|
|
2112
|
+
if (keys.sessionKey === undefined)
|
|
2113
|
+
return undefined;
|
|
2114
|
+
const owned = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.sessionKey);
|
|
2115
|
+
if (owned)
|
|
2116
|
+
return { agent: owned, byContinuedKey: false };
|
|
2117
|
+
if (keys.continuesSessionKey === undefined)
|
|
2118
|
+
return undefined;
|
|
2119
|
+
const continued = this.#agentForSessionKey(workingDirectory, keys.implementationId, keys.continuesSessionKey);
|
|
2120
|
+
return continued && { agent: continued, byContinuedKey: true };
|
|
2121
|
+
}
|
|
2122
|
+
#agentForSessionKey(workingDirectory, implementationId, sessionKey) {
|
|
1653
2123
|
const row = this.#db
|
|
1654
|
-
.prepare("select id, registration_name, registration_ephemeral from agents where
|
|
1655
|
-
.get(
|
|
2124
|
+
.prepare("select id, registration_name, registration_ephemeral from agents where working_directory = ? and implementation_id = ? and session_key = ? and state = 'active'")
|
|
2125
|
+
.get(workingDirectory, implementationId, sessionKey);
|
|
1656
2126
|
if (!row)
|
|
1657
2127
|
return undefined;
|
|
1658
2128
|
return {
|
|
@@ -1683,14 +2153,14 @@ export class Broker {
|
|
|
1683
2153
|
}
|
|
1684
2154
|
#resolveSessionKeyAtAttach(reserved, sessionKey) {
|
|
1685
2155
|
const reservedAgent = this.#db
|
|
1686
|
-
.prepare("select
|
|
2156
|
+
.prepare("select working_directory, implementation_id, session_key from agents where id = ?")
|
|
1687
2157
|
.get(reserved);
|
|
1688
2158
|
if (!reservedAgent)
|
|
1689
2159
|
throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
|
|
1690
2160
|
if (typeof reservedAgent.implementation_id !== "string") {
|
|
1691
2161
|
throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
|
|
1692
2162
|
}
|
|
1693
|
-
const owner = this.#agentForSessionKey(String(reservedAgent.
|
|
2163
|
+
const owner = this.#agentForSessionKey(String(reservedAgent.working_directory), reservedAgent.implementation_id, sessionKey);
|
|
1694
2164
|
if (!owner || owner.agentId === reserved) {
|
|
1695
2165
|
this.#bindSessionKey(reserved, sessionKey, true);
|
|
1696
2166
|
return reserved;
|
|
@@ -1828,19 +2298,12 @@ export class Broker {
|
|
|
1828
2298
|
return Boolean(row);
|
|
1829
2299
|
}
|
|
1830
2300
|
#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
|
-
}
|
|
2301
|
+
const holder = this.#aliasHolder(workspaceId, sessionName, owner);
|
|
2302
|
+
if (!holder)
|
|
2303
|
+
return;
|
|
2304
|
+
throw new ProtocolError("alias_conflict", holder.retired
|
|
2305
|
+
? "Agent alias is a retired name that stays reserved in this workspace"
|
|
2306
|
+
: "Agent alias is already in use in this workspace");
|
|
1844
2307
|
}
|
|
1845
2308
|
#pairSendCount(from, to) {
|
|
1846
2309
|
const row = this.#db
|
|
@@ -2119,10 +2582,11 @@ export class Broker {
|
|
|
2119
2582
|
if (current)
|
|
2120
2583
|
return readStoredAgentId(current.id, "agents.id");
|
|
2121
2584
|
const historical = this.#db
|
|
2122
|
-
.prepare("select a.
|
|
2585
|
+
.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
2586
|
.get(targetWorkspace, normalizeAlias(address.sessionName));
|
|
2124
|
-
if (
|
|
2125
|
-
|
|
2587
|
+
if (historical) {
|
|
2588
|
+
const current = this.#agentAddress(readStoredAgentId(historical.id, "alias_history.agent_id"), this.workspaceNames());
|
|
2589
|
+
throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${current}; try that address instead`);
|
|
2126
2590
|
}
|
|
2127
2591
|
throw new ProtocolError("unknown_recipient", "Unknown recipient");
|
|
2128
2592
|
}
|
|
@@ -2142,7 +2606,7 @@ export class Broker {
|
|
|
2142
2606
|
if (typeof alias !== "string" || alias === "")
|
|
2143
2607
|
return agentId;
|
|
2144
2608
|
const workspace = workspaces.get(String(row.workspace_id)) ?? String(row.workspace_id);
|
|
2145
|
-
return
|
|
2609
|
+
return formatAgentAddress(alias, workspace);
|
|
2146
2610
|
}
|
|
2147
2611
|
#workspaceForName(name, names) {
|
|
2148
2612
|
for (const [workspaceId, workspaceName] of names) {
|
|
@@ -2273,6 +2737,14 @@ function mutatedTable(sql) {
|
|
|
2273
2737
|
throw new Error(`Broker write statement names no table: ${sql}`);
|
|
2274
2738
|
return match[1];
|
|
2275
2739
|
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Read a nullable text column.
|
|
2742
|
+
* @param value - Column value as the driver returned it.
|
|
2743
|
+
* @returns - The text, or null for any other value.
|
|
2744
|
+
*/
|
|
2745
|
+
function textOrNull(value) {
|
|
2746
|
+
return typeof value === "string" ? value : null;
|
|
2747
|
+
}
|
|
2276
2748
|
function contentType(body) {
|
|
2277
2749
|
return typeof body === "string" ? "text" : "json";
|
|
2278
2750
|
}
|