@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,3 +1,4 @@
|
|
|
1
|
+
import { samePurgeTargets } from "../broker/operator-projection.js";
|
|
1
2
|
import { ProtocolError } from "../broker/protocol.js";
|
|
2
3
|
import { OPERATOR_PROTOCOL_VERSION, } from "./protocol.js";
|
|
3
4
|
import { validateOperatorCommand, validateOperatorCommandResult, validateOperatorControlId } from "./validation.js";
|
|
@@ -37,6 +38,8 @@ export class OperatorCommandService {
|
|
|
37
38
|
return this.#expire({ ...command, command: command.command });
|
|
38
39
|
case "archive_agent":
|
|
39
40
|
return this.#archive({ ...command, command: command.command });
|
|
41
|
+
case "purge_agents":
|
|
42
|
+
return this.#purge({ ...command, command: command.command });
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
#ping(command) {
|
|
@@ -90,31 +93,16 @@ export class OperatorCommandService {
|
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
#archive(command) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
throw new Error("Broker returned a mismatched archive acceptance");
|
|
98
|
-
}
|
|
99
|
-
return validateOperatorCommandResult({
|
|
100
|
-
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
101
|
-
type: "archive_accepted",
|
|
102
|
-
status: "accepted",
|
|
103
|
-
commandId: command.commandId,
|
|
104
|
-
acceptedAt: accepted.acceptedAt,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
return rejected(command.commandId, rejectionReason(error));
|
|
109
|
-
}
|
|
96
|
+
return agentCommandResult(command, () => this.#broker.acceptOperatorArchive(command.commandId, command.command.targetAgentId));
|
|
97
|
+
}
|
|
98
|
+
#purge(command) {
|
|
99
|
+
return agentCommandResult(command, () => this.#broker.acceptOperatorPurge(command.commandId, command.command.targetAgentIds));
|
|
110
100
|
}
|
|
111
101
|
#priorResult(command, prior) {
|
|
112
|
-
const expectedTarget = command.command.type === "expire_message" ? command.command.messageId : command.command.targetAgentId;
|
|
113
102
|
if (prior.commandId !== command.commandId)
|
|
114
103
|
throw new Error("Broker returned a mismatched command lookup");
|
|
115
|
-
if (
|
|
104
|
+
if (!sameTarget(command.command, prior))
|
|
116
105
|
return rejected(command.commandId, "command_id_conflict");
|
|
117
|
-
}
|
|
118
106
|
if (prior.type === "ping_agent")
|
|
119
107
|
validateOperatorControlId(prior.controlId);
|
|
120
108
|
if (prior.type === "ping_agent")
|
|
@@ -128,13 +116,61 @@ export class OperatorCommandService {
|
|
|
128
116
|
});
|
|
129
117
|
return validateOperatorCommandResult({
|
|
130
118
|
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
131
|
-
type: prior.type
|
|
119
|
+
type: ACCEPTED_RESULT_TYPES[prior.type],
|
|
132
120
|
status: "accepted",
|
|
133
121
|
commandId: prior.commandId,
|
|
134
122
|
acceptedAt: prior.acceptedAt,
|
|
135
123
|
});
|
|
136
124
|
}
|
|
137
125
|
}
|
|
126
|
+
/** Names the result each command kind without a control correlation is accepted with. */
|
|
127
|
+
const ACCEPTED_RESULT_TYPES = {
|
|
128
|
+
expire_message: "expiry_accepted",
|
|
129
|
+
archive_agent: "archive_accepted",
|
|
130
|
+
purge_agents: "purge_accepted",
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Run one durable agent command and answer it with its acceptance or a rejection.
|
|
134
|
+
* @param command - Command being admitted, whose kind and target the broker's record must match.
|
|
135
|
+
* @param accept - Broker transaction that applies the command and records its acceptance.
|
|
136
|
+
* @returns - The acceptance, or a rejection naming why the broker refused or failed.
|
|
137
|
+
*/
|
|
138
|
+
function agentCommandResult(command, accept) {
|
|
139
|
+
try {
|
|
140
|
+
const { record, status } = accept();
|
|
141
|
+
assertAcceptanceStatus(status);
|
|
142
|
+
if (record.commandId !== command.commandId || !sameTarget(command.command, record)) {
|
|
143
|
+
throw new Error(`Broker returned a mismatched ${command.command.type} acceptance`);
|
|
144
|
+
}
|
|
145
|
+
return validateOperatorCommandResult({
|
|
146
|
+
protocolVersion: OPERATOR_PROTOCOL_VERSION,
|
|
147
|
+
type: ACCEPTED_RESULT_TYPES[command.command.type],
|
|
148
|
+
status: "accepted",
|
|
149
|
+
commandId: command.commandId,
|
|
150
|
+
acceptedAt: record.acceptedAt,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return rejected(command.commandId, rejectionReason(error));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Report whether a stored acceptance answers the same request a retried command makes.
|
|
159
|
+
* @param command - Command carried by the retry.
|
|
160
|
+
* @param prior - Acceptance stored under the retry's command id.
|
|
161
|
+
* @returns - Whether both name the same command kind and the same target.
|
|
162
|
+
*/
|
|
163
|
+
function sameTarget(command, prior) {
|
|
164
|
+
switch (command.type) {
|
|
165
|
+
case "ping_agent":
|
|
166
|
+
case "archive_agent":
|
|
167
|
+
return prior.type === command.type && prior.targetId === command.targetAgentId;
|
|
168
|
+
case "expire_message":
|
|
169
|
+
return prior.type === command.type && prior.targetId === command.messageId;
|
|
170
|
+
case "purge_agents":
|
|
171
|
+
return prior.type === command.type && samePurgeTargets(prior.targetIds, command.targetAgentIds);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
138
174
|
function pingAccepted(commandId, accepted) {
|
|
139
175
|
if (accepted.type !== "ping_agent" || accepted.commandId !== commandId)
|
|
140
176
|
throw new Error("Broker returned the wrong ping command acceptance");
|
|
@@ -29,6 +29,15 @@ export declare const OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES: number;
|
|
|
29
29
|
export declare const OPERATOR_SNAPSHOT_CHUNK_BYTES: number;
|
|
30
30
|
/** Bounds every newline-delimited server frame after JSON encoding. */
|
|
31
31
|
export declare const OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES: number;
|
|
32
|
+
/** Bounds every newline-delimited client request after JSON encoding; a longer one ends the connection. */
|
|
33
|
+
export declare const OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* Bounds how many agents one purge command names.
|
|
36
|
+
*
|
|
37
|
+
* A purge naming this many agents at the longest agent id still fits in one request under
|
|
38
|
+
* `OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const OPERATOR_PURGE_TARGET_LIMIT = 256;
|
|
32
41
|
/** Represents JSON data that is safe to cross the operator boundary. */
|
|
33
42
|
export type OperatorJsonValue = null | boolean | number | string | OperatorJsonValue[] | OperatorJsonObject;
|
|
34
43
|
/** Represents an object whose values are safe to cross the operator boundary. */
|
|
@@ -143,9 +152,9 @@ export type OperatorAgent = {
|
|
|
143
152
|
*/
|
|
144
153
|
archivedAt: number | null;
|
|
145
154
|
/**
|
|
146
|
-
* Reports whether the recorded
|
|
155
|
+
* Reports whether the agent's recorded working directory still resolves on the broker's machine.
|
|
147
156
|
*
|
|
148
|
-
* Presentation-only, like the rest of repository grouping: a removed
|
|
157
|
+
* Presentation-only, like the rest of repository grouping: a removed directory archives its
|
|
149
158
|
* agents in the operator view without changing broker lifecycle, routing, or addressability.
|
|
150
159
|
*/
|
|
151
160
|
workspaceAvailable: boolean;
|
|
@@ -310,6 +319,11 @@ export type OperatorCommand = {
|
|
|
310
319
|
type: "archive_agent";
|
|
311
320
|
/** Identifies the target using broker identity, not repository projection. */
|
|
312
321
|
targetAgentId: AgentId;
|
|
322
|
+
} | {
|
|
323
|
+
/** Requests that agent identities and their records be deleted in one transaction. */
|
|
324
|
+
type: "purge_agents";
|
|
325
|
+
/** Distinct broker identities to delete, at least one; a purge of any that is already gone is refused whole. */
|
|
326
|
+
targetAgentIds: readonly AgentId[];
|
|
313
327
|
};
|
|
314
328
|
/** Carries one versioned operator command request. */
|
|
315
329
|
export type OperatorCommandEnvelope = {
|
|
@@ -367,6 +381,17 @@ export type OperatorCommandResult = {
|
|
|
367
381
|
commandId: string;
|
|
368
382
|
/** Records broker acceptance time. */
|
|
369
383
|
acceptedAt: number;
|
|
384
|
+
} | {
|
|
385
|
+
/** Identifies the operator wire contract. */
|
|
386
|
+
protocolVersion: typeof OPERATOR_PROTOCOL_VERSION;
|
|
387
|
+
/** Identifies agent-purge command acceptance. */
|
|
388
|
+
type: "purge_accepted";
|
|
389
|
+
/** Reports that the broker accepted the command request. */
|
|
390
|
+
status: "accepted";
|
|
391
|
+
/** Correlates the result with the submitted command. */
|
|
392
|
+
commandId: string;
|
|
393
|
+
/** Records broker acceptance time. */
|
|
394
|
+
acceptedAt: number;
|
|
370
395
|
} | {
|
|
371
396
|
/** Identifies the operator wire contract. */
|
|
372
397
|
protocolVersion: typeof OPERATOR_PROTOCOL_VERSION;
|
|
@@ -408,6 +433,13 @@ export type OperatorCorrelatedCommandResult = {
|
|
|
408
433
|
result: Extract<OperatorCommandResult, {
|
|
409
434
|
type: "archive_accepted" | "command_rejected";
|
|
410
435
|
}>;
|
|
436
|
+
} | {
|
|
437
|
+
/** Carries the originating purge request. */
|
|
438
|
+
command: OperatorCommandEnvelopeFor<"purge_agents">;
|
|
439
|
+
/** Carries purge acceptance or a command-agnostic rejection. */
|
|
440
|
+
result: Extract<OperatorCommandResult, {
|
|
441
|
+
type: "purge_accepted" | "command_rejected";
|
|
442
|
+
}>;
|
|
411
443
|
};
|
|
412
444
|
/** Classifies one event relative to a consumer cursor without conflating recovery actions. */
|
|
413
445
|
export type OperatorCursorResult = {
|
|
@@ -28,6 +28,15 @@ export const OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES = 512 * 1_024 * 1_024;
|
|
|
28
28
|
export const OPERATOR_SNAPSHOT_CHUNK_BYTES = 48 * 1_024;
|
|
29
29
|
/** Bounds every newline-delimited server frame after JSON encoding. */
|
|
30
30
|
export const OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES = 128 * 1_024;
|
|
31
|
+
/** Bounds every newline-delimited client request after JSON encoding; a longer one ends the connection. */
|
|
32
|
+
export const OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES = 64 * 1_024;
|
|
33
|
+
/**
|
|
34
|
+
* Bounds how many agents one purge command names.
|
|
35
|
+
*
|
|
36
|
+
* A purge naming this many agents at the longest agent id still fits in one request under
|
|
37
|
+
* `OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES`.
|
|
38
|
+
*/
|
|
39
|
+
export const OPERATOR_PURGE_TARGET_LIMIT = 256;
|
|
31
40
|
/**
|
|
32
41
|
* Names every stable category a command rejection can carry.
|
|
33
42
|
*
|
|
@@ -5,6 +5,8 @@ export declare const REPOSITORY_GIT_OUTPUT_LIMIT = 65536;
|
|
|
5
5
|
export type SystemRepositoryProjectionOptions = {
|
|
6
6
|
/** Selects the Git executable without mutating process PATH. */
|
|
7
7
|
gitExecutable?: string;
|
|
8
|
+
/** Bounds each Git command's run time in milliseconds; unbounded when omitted. */
|
|
9
|
+
gitTimeoutMs?: number;
|
|
8
10
|
};
|
|
9
11
|
/**
|
|
10
12
|
* Compose production filesystem and Git discovery adapters.
|
|
@@ -12,3 +14,30 @@ export type SystemRepositoryProjectionOptions = {
|
|
|
12
14
|
* @returns Dependencies whose process execution is isolated from pure projection logic.
|
|
13
15
|
*/
|
|
14
16
|
export declare function createSystemRepositoryProjectionDependencies(options?: SystemRepositoryProjectionOptions): RepositoryProjectionDependencies;
|
|
17
|
+
/** Reports how one Git command ended, separating the two answers Git gives for a path it cannot serve. */
|
|
18
|
+
export type GitCommandResult = {
|
|
19
|
+
kind: "ok";
|
|
20
|
+
stdout: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "not_git";
|
|
23
|
+
} | {
|
|
24
|
+
kind: "no_worktree";
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Report the Git directory every worktree of the repository containing a directory shares.
|
|
28
|
+
* @param path - Absolute directory inside a worktree, or inside a bare repository.
|
|
29
|
+
* @param options - Executable and time bound for the Git command.
|
|
30
|
+
* @returns The repository's common Git directory, canonical and absolute, or null when the directory
|
|
31
|
+
* is not inside a Git repository.
|
|
32
|
+
* @throws {RepositoryDiscoveryError} When Git is unavailable, times out, or answers with something other than one absolute path.
|
|
33
|
+
*/
|
|
34
|
+
export declare function discoverRepositoryCommonDirectory(path: string, options?: SystemRepositoryProjectionOptions): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Run one Git command about the repository its arguments name, with a bounded, captured output.
|
|
37
|
+
* @param executable - Git executable to spawn.
|
|
38
|
+
* @param args - Arguments, including the `-C` directory the command is about.
|
|
39
|
+
* @param timeoutMs - Milliseconds the command may run before it is treated as unavailable; unbounded when undefined.
|
|
40
|
+
* @returns The command's output, or which of Git's two refusals it answered with.
|
|
41
|
+
* @throws {RepositoryDiscoveryError} When Git cannot be run, exceeds the timeout or the output limit, or fails for any other reason.
|
|
42
|
+
*/
|
|
43
|
+
export declare function runGit(executable: string, args: string[], timeoutMs: number | undefined): GitCommandResult;
|
|
@@ -11,6 +11,7 @@ export const REPOSITORY_GIT_OUTPUT_LIMIT = 65_536;
|
|
|
11
11
|
*/
|
|
12
12
|
export function createSystemRepositoryProjectionDependencies(options = {}) {
|
|
13
13
|
const gitExecutable = options.gitExecutable ?? "git";
|
|
14
|
+
const timeoutMs = options.gitTimeoutMs;
|
|
14
15
|
return {
|
|
15
16
|
fileSystem: {
|
|
16
17
|
canonicalPath: (path) => realpathSync(path),
|
|
@@ -18,25 +19,52 @@ export function createSystemRepositoryProjectionDependencies(options = {}) {
|
|
|
18
19
|
},
|
|
19
20
|
git: {
|
|
20
21
|
inspect: (workspacePath) => {
|
|
21
|
-
const topLevel = runGit(gitExecutable, ["-C", workspacePath, "rev-parse", "--show-toplevel"],
|
|
22
|
+
const topLevel = runGit(gitExecutable, ["-C", workspacePath, "rev-parse", "--show-toplevel"], timeoutMs);
|
|
22
23
|
if (topLevel.kind === "not_git")
|
|
23
24
|
return topLevel;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
if (topology.kind === "not_git")
|
|
25
|
+
const topology = runGit(gitExecutable, ["-C", workspacePath, "worktree", "list", "--porcelain", "-z"], timeoutMs);
|
|
26
|
+
if (topology.kind !== "ok")
|
|
27
27
|
throw new RepositoryDiscoveryError("invalid", "Git topology disappeared");
|
|
28
|
-
const repositoryRoot = parsePrimaryWorktree(topology.stdout
|
|
28
|
+
const repositoryRoot = parsePrimaryWorktree(topology.stdout);
|
|
29
|
+
if (topLevel.kind === "no_worktree")
|
|
30
|
+
return { kind: "git", repositoryRoot, worktreeRoot: repositoryRoot };
|
|
31
|
+
const worktreeRoot = parseSingleAbsolutePath(topLevel.stdout, "Git top-level output is invalid");
|
|
29
32
|
return { kind: "git", repositoryRoot, worktreeRoot };
|
|
30
33
|
},
|
|
31
34
|
},
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Report the Git directory every worktree of the repository containing a directory shares.
|
|
39
|
+
* @param path - Absolute directory inside a worktree, or inside a bare repository.
|
|
40
|
+
* @param options - Executable and time bound for the Git command.
|
|
41
|
+
* @returns The repository's common Git directory, canonical and absolute, or null when the directory
|
|
42
|
+
* is not inside a Git repository.
|
|
43
|
+
* @throws {RepositoryDiscoveryError} When Git is unavailable, times out, or answers with something other than one absolute path.
|
|
44
|
+
*/
|
|
45
|
+
export function discoverRepositoryCommonDirectory(path, options = {}) {
|
|
46
|
+
const answer = runGit(options.gitExecutable ?? "git", ["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"], options.gitTimeoutMs);
|
|
47
|
+
if (answer.kind !== "ok")
|
|
48
|
+
return null;
|
|
49
|
+
return parseSingleAbsolutePath(answer.stdout, "Git common directory output is invalid");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run one Git command about the repository its arguments name, with a bounded, captured output.
|
|
53
|
+
* @param executable - Git executable to spawn.
|
|
54
|
+
* @param args - Arguments, including the `-C` directory the command is about.
|
|
55
|
+
* @param timeoutMs - Milliseconds the command may run before it is treated as unavailable; unbounded when undefined.
|
|
56
|
+
* @returns The command's output, or which of Git's two refusals it answered with.
|
|
57
|
+
* @throws {RepositoryDiscoveryError} When Git cannot be run, exceeds the timeout or the output limit, or fails for any other reason.
|
|
58
|
+
*/
|
|
59
|
+
export function runGit(executable, args, timeoutMs) {
|
|
60
|
+
// Git redirects itself to whatever these name, which would answer for a repository other than the one asked about.
|
|
61
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"].includes(name)));
|
|
35
62
|
const result = spawnSync(executable, args, {
|
|
36
63
|
encoding: "utf8",
|
|
37
|
-
env: { ...
|
|
64
|
+
env: { ...environment, LC_ALL: "C", LANG: "C" },
|
|
38
65
|
maxBuffer: REPOSITORY_GIT_OUTPUT_LIMIT,
|
|
39
66
|
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
40
68
|
});
|
|
41
69
|
if (result.error) {
|
|
42
70
|
const code = "code" in result.error ? result.error.code : undefined;
|
|
@@ -53,8 +81,10 @@ function runGit(executable, args, allowNotGit) {
|
|
|
53
81
|
if (result.signal !== null)
|
|
54
82
|
throw new RepositoryDiscoveryError("unavailable", "Git discovery process was interrupted");
|
|
55
83
|
if (result.status !== 0) {
|
|
56
|
-
if (
|
|
84
|
+
if (result.status === 128 && /not a git repository/u.test(stderr))
|
|
57
85
|
return { kind: "not_git" };
|
|
86
|
+
if (result.status === 128 && /must be run in a work tree/u.test(stderr))
|
|
87
|
+
return { kind: "no_worktree" };
|
|
58
88
|
throw new RepositoryDiscoveryError("unavailable", "Git discovery command failed");
|
|
59
89
|
}
|
|
60
90
|
return { kind: "ok", stdout };
|
|
@@ -66,7 +96,7 @@ function parseSingleAbsolutePath(output, message) {
|
|
|
66
96
|
}
|
|
67
97
|
return normalized;
|
|
68
98
|
}
|
|
69
|
-
function parsePrimaryWorktree(output
|
|
99
|
+
function parsePrimaryWorktree(output) {
|
|
70
100
|
if (!output.endsWith("\0\0"))
|
|
71
101
|
throw new RepositoryDiscoveryError("invalid", "Git topology output is truncated");
|
|
72
102
|
const tokens = output.split("\0");
|
|
@@ -100,9 +130,6 @@ function parsePrimaryWorktree(output, currentWorktree) {
|
|
|
100
130
|
throw new RepositoryDiscoveryError("invalid", "Only the primary Git topology record may be bare");
|
|
101
131
|
}
|
|
102
132
|
}
|
|
103
|
-
if (!worktrees.has(currentWorktree)) {
|
|
104
|
-
throw new RepositoryDiscoveryError("invalid", "Git topology omitted the current worktree");
|
|
105
|
-
}
|
|
106
133
|
const primary = topology[0]?.path;
|
|
107
134
|
if (!primary)
|
|
108
135
|
throw new RepositoryDiscoveryError("invalid", "Git topology omitted its primary worktree");
|
|
@@ -3,7 +3,7 @@ import type { OperatorRepository, OperatorWorktree } from "./protocol.js";
|
|
|
3
3
|
export type RepositoryGitLocation = {
|
|
4
4
|
/** Supplies the root of the primary repository worktree. */
|
|
5
5
|
repositoryRoot: string;
|
|
6
|
-
/** Supplies the root of the worktree containing the inspected
|
|
6
|
+
/** Supplies the root of the worktree containing the inspected directory. */
|
|
7
7
|
worktreeRoot: string;
|
|
8
8
|
};
|
|
9
9
|
/** Reports either confirmed Git topology or a confirmed non-Git workspace. */
|
|
@@ -16,13 +16,13 @@ export type RepositoryGitInspection = ({
|
|
|
16
16
|
export interface RepositoryFileSystemPort {
|
|
17
17
|
/**
|
|
18
18
|
* Resolve aliases and symbolic links in one existing path.
|
|
19
|
-
* @param path - Absolute
|
|
19
|
+
* @param path - Absolute path or Git root to canonicalize.
|
|
20
20
|
* @returns The canonical absolute filesystem path.
|
|
21
21
|
*/
|
|
22
22
|
canonicalPath(path: string): string;
|
|
23
23
|
/**
|
|
24
24
|
* Report whether one recorded path still resolves on this machine.
|
|
25
|
-
* @param path - Absolute
|
|
25
|
+
* @param path - Absolute path recorded at registration.
|
|
26
26
|
* @returns True when the path resolves through any symbolic links, false when it has been removed, is a dangling link, or is unreachable.
|
|
27
27
|
*/
|
|
28
28
|
exists(path: string): boolean;
|
|
@@ -30,12 +30,28 @@ export interface RepositoryFileSystemPort {
|
|
|
30
30
|
/** Discovers Git topology without granting it broker routing authority. */
|
|
31
31
|
export interface RepositoryGitPort {
|
|
32
32
|
/**
|
|
33
|
-
* Inspect one canonical
|
|
34
|
-
* @param
|
|
33
|
+
* Inspect one canonical directory for Git repository topology.
|
|
34
|
+
* @param path - Canonical absolute directory to inspect.
|
|
35
35
|
* @returns Confirmed Git topology or a confirmed non-Git result.
|
|
36
36
|
*/
|
|
37
|
-
inspect(
|
|
37
|
+
inspect(path: string): RepositoryGitInspection;
|
|
38
38
|
}
|
|
39
|
+
/** Configures how long one Git inspection answers for its path. */
|
|
40
|
+
export type RememberedInspectionOptions = {
|
|
41
|
+
/** Supplies the clock the expiry is measured against. */
|
|
42
|
+
now: () => number;
|
|
43
|
+
/** Milliseconds an inspection answers for before the path is inspected again. */
|
|
44
|
+
ttlMs: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a Git port so each path is inspected at most once per expiry window.
|
|
48
|
+
*
|
|
49
|
+
* A thrown inspection is remembered like an answered one, and rethrown for the rest of its window.
|
|
50
|
+
* @param git - Port that performs the inspection when nothing current is remembered.
|
|
51
|
+
* @param options - Clock and expiry window.
|
|
52
|
+
* @returns A port answering repeated requests for one path from the last inspection within the window.
|
|
53
|
+
*/
|
|
54
|
+
export declare function rememberRepositoryInspections(git: RepositoryGitPort, options: RememberedInspectionOptions): RepositoryGitPort;
|
|
39
55
|
/** Supplies filesystem and Git collaborators to repository projection. */
|
|
40
56
|
export type RepositoryProjectionDependencies = {
|
|
41
57
|
/** Canonicalizes discovered paths. */
|
|
@@ -49,7 +65,7 @@ export type ProjectedRepositoryLocation = {
|
|
|
49
65
|
repository: Omit<OperatorRepository, "name">;
|
|
50
66
|
/** Carries worktree detail for the inspected workspace. */
|
|
51
67
|
worktree: OperatorWorktree;
|
|
52
|
-
/** Reports whether the
|
|
68
|
+
/** Reports whether the inspected path still resolves, which decides operator archive membership. */
|
|
53
69
|
available: boolean;
|
|
54
70
|
};
|
|
55
71
|
/** Reports invalid or unavailable repository projection inputs. */
|
|
@@ -66,14 +82,15 @@ export declare class RepositoryDiscoveryError extends RepositoryProjectionError
|
|
|
66
82
|
constructor(code: "unavailable" | "invalid" | "output_limit", message: string);
|
|
67
83
|
}
|
|
68
84
|
/**
|
|
69
|
-
* Project one broker workspace
|
|
85
|
+
* Project one directory, a broker workspace or an agent's working directory, into repository
|
|
86
|
+
* grouping and worktree detail.
|
|
70
87
|
*
|
|
71
|
-
* A
|
|
88
|
+
* A directory whose path no longer resolves is projected from its recorded path alone. Neither
|
|
72
89
|
* canonicalization nor Git discovery can run against a removed directory, and both throw rather
|
|
73
90
|
* than reporting absence, so the removal is detected before either is attempted.
|
|
74
|
-
* @param workspacePath - Absolute broker workspace path to inspect.
|
|
91
|
+
* @param workspacePath - Absolute broker workspace path, or agent working directory, to inspect.
|
|
75
92
|
* @param dependencies - Explicit filesystem and Git discovery collaborators.
|
|
76
|
-
* @returns Presentation-only repository and worktree values, and whether the
|
|
93
|
+
* @returns Presentation-only repository and worktree values, and whether the directory still resolves.
|
|
77
94
|
*/
|
|
78
95
|
export declare function projectRepositoryLocation(workspacePath: string, dependencies: RepositoryProjectionDependencies): ProjectedRepositoryLocation;
|
|
79
96
|
export {};
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a Git port so each path is inspected at most once per expiry window.
|
|
4
|
+
*
|
|
5
|
+
* A thrown inspection is remembered like an answered one, and rethrown for the rest of its window.
|
|
6
|
+
* @param git - Port that performs the inspection when nothing current is remembered.
|
|
7
|
+
* @param options - Clock and expiry window.
|
|
8
|
+
* @returns A port answering repeated requests for one path from the last inspection within the window.
|
|
9
|
+
*/
|
|
10
|
+
export function rememberRepositoryInspections(git, options) {
|
|
11
|
+
const remembered = new Map();
|
|
12
|
+
const inspectAndRemember = (path, now) => {
|
|
13
|
+
let outcome;
|
|
14
|
+
try {
|
|
15
|
+
outcome = { kind: "answered", inspection: git.inspect(path) };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
outcome = { kind: "threw", error };
|
|
19
|
+
}
|
|
20
|
+
remembered.set(path, { outcome, expiresAt: now + options.ttlMs });
|
|
21
|
+
return outcome;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
inspect: (path) => {
|
|
25
|
+
const now = options.now();
|
|
26
|
+
const current = remembered.get(path);
|
|
27
|
+
const outcome = current && current.expiresAt > now ? current.outcome : inspectAndRemember(path, now);
|
|
28
|
+
if (outcome.kind === "threw")
|
|
29
|
+
throw outcome.error;
|
|
30
|
+
return outcome.inspection;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
2
34
|
/** Reports invalid or unavailable repository projection inputs. */
|
|
3
35
|
class RepositoryProjectionError extends Error {
|
|
4
36
|
}
|
|
@@ -17,14 +49,15 @@ export class RepositoryDiscoveryError extends RepositoryProjectionError {
|
|
|
17
49
|
}
|
|
18
50
|
}
|
|
19
51
|
/**
|
|
20
|
-
* Project one broker workspace
|
|
52
|
+
* Project one directory, a broker workspace or an agent's working directory, into repository
|
|
53
|
+
* grouping and worktree detail.
|
|
21
54
|
*
|
|
22
|
-
* A
|
|
55
|
+
* A directory whose path no longer resolves is projected from its recorded path alone. Neither
|
|
23
56
|
* canonicalization nor Git discovery can run against a removed directory, and both throw rather
|
|
24
57
|
* than reporting absence, so the removal is detected before either is attempted.
|
|
25
|
-
* @param workspacePath - Absolute broker workspace path to inspect.
|
|
58
|
+
* @param workspacePath - Absolute broker workspace path, or agent working directory, to inspect.
|
|
26
59
|
* @param dependencies - Explicit filesystem and Git discovery collaborators.
|
|
27
|
-
* @returns Presentation-only repository and worktree values, and whether the
|
|
60
|
+
* @returns Presentation-only repository and worktree values, and whether the directory still resolves.
|
|
28
61
|
*/
|
|
29
62
|
export function projectRepositoryLocation(workspacePath, dependencies) {
|
|
30
63
|
if (!dependencies.fileSystem.exists(workspacePath)) {
|
|
@@ -3,9 +3,8 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { LOOPBACK_HOST } from "../configuration-defaults.js";
|
|
5
5
|
import { closeServer, listenServer, urlHost } from "../shared/http.js";
|
|
6
|
-
import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, } from "./protocol.js";
|
|
6
|
+
import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES, } from "./protocol.js";
|
|
7
7
|
import { OperatorValidationError, validateOperatorCommandResultForCommand, validateOperatorSnapshotByteLength, validateOperatorTransportRequest, } from "./validation.js";
|
|
8
|
-
const INPUT_LIMIT_BYTES = 64 * 1_024;
|
|
9
8
|
const LIVE_QUEUE_LIMIT = 32;
|
|
10
9
|
const DIAGNOSTIC_LIMIT = 256;
|
|
11
10
|
const DEFAULT_MAX_CONNECTIONS = 64;
|
|
@@ -214,10 +213,16 @@ class OperatorConnection {
|
|
|
214
213
|
this.#socket.destroy();
|
|
215
214
|
}
|
|
216
215
|
#receive(chunk) {
|
|
217
|
-
|
|
216
|
+
// A connection that is closing reads nothing more, so input from a peer that has stopped reading
|
|
217
|
+
// cannot pile up while its last frames wait to flush.
|
|
218
|
+
if (this.#closed || this.#closeAfterFlush) {
|
|
219
|
+
this.#buffer = "";
|
|
218
220
|
return;
|
|
221
|
+
}
|
|
219
222
|
this.#buffer += chunk;
|
|
220
|
-
|
|
223
|
+
// One read can carry several whole requests, so only the unterminated one is measured here.
|
|
224
|
+
const unterminated = this.#buffer.slice(this.#buffer.lastIndexOf("\n") + 1);
|
|
225
|
+
if (Buffer.byteLength(unterminated, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
|
|
221
226
|
this.#malformed();
|
|
222
227
|
return;
|
|
223
228
|
}
|
|
@@ -227,7 +232,7 @@ class OperatorConnection {
|
|
|
227
232
|
return;
|
|
228
233
|
const line = this.#buffer.slice(0, newline);
|
|
229
234
|
this.#buffer = this.#buffer.slice(newline + 1);
|
|
230
|
-
if (!line || Buffer.byteLength(line, "utf8") >
|
|
235
|
+
if (!line || Buffer.byteLength(line, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
|
|
231
236
|
this.#malformed();
|
|
232
237
|
return;
|
|
233
238
|
}
|
|
@@ -8,7 +8,13 @@ const UPDATE_DIAGNOSTIC_LIMIT = 64;
|
|
|
8
8
|
/** Broker tables whose rows feed the repository and agent halves of an operator snapshot. */
|
|
9
9
|
const AGENT_SOURCE_TABLES = ["agents", "sessions", "workspaces", "alias_history"];
|
|
10
10
|
/** Broker tables whose rows feed the traffic half of an operator snapshot. */
|
|
11
|
-
const TRAFFIC_SOURCE_TABLES = [
|
|
11
|
+
const TRAFFIC_SOURCE_TABLES = [
|
|
12
|
+
"messages",
|
|
13
|
+
"message_events",
|
|
14
|
+
"claims",
|
|
15
|
+
"traffic_positions",
|
|
16
|
+
"operator_ping_audits",
|
|
17
|
+
];
|
|
12
18
|
/** Produces complete snapshots and durable strictly ordered projection changes. */
|
|
13
19
|
export class OperatorUpdateService {
|
|
14
20
|
#source;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
|
|
1
|
+
import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_PURGE_TARGET_LIMIT, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
|
|
2
2
|
import { BROKER_OPERATOR_TRAFFIC_LIMIT, hasControlCharacter, isAgentId, isImplementationId, isOpaqueId, lifecycleEventState, MESSAGE_BODY_LIMIT_BYTES, MESSAGE_SUBJECT_LIMIT_CHARACTERS, MESSAGE_TYPE_LIMIT_CHARACTERS, } from "../broker/protocol.js";
|
|
3
3
|
import { MESSAGE_STATES } from "../broker/schema.js";
|
|
4
4
|
import { exactFields as guardExactFields, readArray as guardReadArray, readOwnEnumerableEntries as guardReadOwnEnumerableEntries, readRecord as guardReadRecord, } from "../shared/guards.js";
|
|
@@ -407,6 +407,22 @@ export function validateOperatorCommandResult(value) {
|
|
|
407
407
|
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
408
408
|
acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
|
|
409
409
|
};
|
|
410
|
+
case "purge_accepted":
|
|
411
|
+
exactFields({
|
|
412
|
+
record,
|
|
413
|
+
required: ["protocolVersion", "type", "status", "commandId", "acceptedAt"],
|
|
414
|
+
optional: [],
|
|
415
|
+
path: "commandResult",
|
|
416
|
+
});
|
|
417
|
+
if (record.status !== "accepted")
|
|
418
|
+
throw new OperatorValidationError("commandResult.status", "purge result must be accepted");
|
|
419
|
+
return {
|
|
420
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
|
|
421
|
+
type: "purge_accepted",
|
|
422
|
+
status: "accepted",
|
|
423
|
+
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
424
|
+
acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
|
|
425
|
+
};
|
|
410
426
|
case "command_rejected":
|
|
411
427
|
exactFields({
|
|
412
428
|
record,
|
|
@@ -452,6 +468,10 @@ export function validateOperatorCommandResultForCommand(commandValue, resultValu
|
|
|
452
468
|
if (result.type === "archive_accepted" || result.type === "command_rejected")
|
|
453
469
|
return { command: { ...command, command: command.command }, result };
|
|
454
470
|
break;
|
|
471
|
+
case "purge_agents":
|
|
472
|
+
if (result.type === "purge_accepted" || result.type === "command_rejected")
|
|
473
|
+
return { command: { ...command, command: command.command }, result };
|
|
474
|
+
break;
|
|
455
475
|
}
|
|
456
476
|
throw new OperatorValidationError("commandResult.type", `${resultDescriptions[result.type]} cannot answer ${commandDescriptions[command.command.type]}`);
|
|
457
477
|
}
|
|
@@ -708,11 +728,13 @@ const commandDescriptions = {
|
|
|
708
728
|
ping_agent: "a ping command",
|
|
709
729
|
expire_message: "an expiry command",
|
|
710
730
|
archive_agent: "an archive command",
|
|
731
|
+
purge_agents: "a purge command",
|
|
711
732
|
};
|
|
712
733
|
const resultDescriptions = {
|
|
713
734
|
ping_accepted: "ping acceptance",
|
|
714
735
|
expiry_accepted: "expiry acceptance",
|
|
715
736
|
archive_accepted: "archive acceptance",
|
|
737
|
+
purge_accepted: "purge acceptance",
|
|
716
738
|
command_rejected: "rejection",
|
|
717
739
|
};
|
|
718
740
|
function validateCommandMember(value) {
|
|
@@ -727,6 +749,9 @@ function validateCommandMember(value) {
|
|
|
727
749
|
case "archive_agent":
|
|
728
750
|
exactFields({ record, required: ["type", "targetAgentId"], optional: [], path: "commandEnvelope.command" });
|
|
729
751
|
return { type: "archive_agent", targetAgentId: readAgentId(record.targetAgentId, "command.targetAgentId") };
|
|
752
|
+
case "purge_agents":
|
|
753
|
+
exactFields({ record, required: ["type", "targetAgentIds"], optional: [], path: "commandEnvelope.command" });
|
|
754
|
+
return { type: "purge_agents", targetAgentIds: readPurgeTargets(record.targetAgentIds, "command.targetAgentIds") };
|
|
730
755
|
default:
|
|
731
756
|
throw new OperatorValidationError("commandEnvelope.command.type", "unknown command");
|
|
732
757
|
}
|
|
@@ -858,6 +883,15 @@ function readAgentId(value, path) {
|
|
|
858
883
|
throw new OperatorValidationError(path, "invalid agent identity");
|
|
859
884
|
return id;
|
|
860
885
|
}
|
|
886
|
+
function readPurgeTargets(value, path) {
|
|
887
|
+
const entries = readArray(value, path);
|
|
888
|
+
if (entries.length === 0 || entries.length > OPERATOR_PURGE_TARGET_LIMIT)
|
|
889
|
+
throw new OperatorValidationError(path, "must name between one and the bounded number of agents");
|
|
890
|
+
const ids = entries.map((entry, index) => readAgentId(entry, `${path}[${String(index)}]`));
|
|
891
|
+
if (new Set(ids).size !== ids.length)
|
|
892
|
+
throw new OperatorValidationError(path, "must not repeat an agent");
|
|
893
|
+
return ids;
|
|
894
|
+
}
|
|
861
895
|
function readOpaqueId(value, path) {
|
|
862
896
|
const id = readNonemptyString(value, path);
|
|
863
897
|
if (!isOpaqueId(id))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A `major.minor.patch` version as three numbers. */
|
|
2
|
+
export type NumericVersion = readonly [number, number, number];
|
|
3
|
+
/**
|
|
4
|
+
* Read the `major.minor.patch` numbers of a version string.
|
|
5
|
+
* @param version - Version text, such as a runtime's reported version or a package release.
|
|
6
|
+
* @param extent - `prefix` accepts trailing text such as a prerelease or build suffix and ignores it;
|
|
7
|
+
* `exact` rejects any version carrying one.
|
|
8
|
+
* @returns - The three numbers, or undefined when the text does not start with, or is not exactly, a
|
|
9
|
+
* numeric version.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseNumericVersion(version: string, extent: "prefix" | "exact"): NumericVersion | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Order two numeric versions.
|
|
14
|
+
* @param left - First version.
|
|
15
|
+
* @param right - Second version.
|
|
16
|
+
* @returns - Negative when `left` is lower, positive when it is higher, zero when they are equal.
|
|
17
|
+
*/
|
|
18
|
+
export declare function compareNumericVersions(left: NumericVersion, right: NumericVersion): number;
|