@orgloop/agentctl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/adapters/claude-code.d.ts +83 -0
- package/dist/adapters/claude-code.js +783 -0
- package/dist/adapters/openclaw.d.ts +88 -0
- package/dist/adapters/openclaw.js +297 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +808 -0
- package/dist/client/daemon-client.d.ts +6 -0
- package/dist/client/daemon-client.js +81 -0
- package/dist/compat-shim.d.ts +2 -0
- package/dist/compat-shim.js +15 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +2 -0
- package/dist/daemon/fuse-engine.d.ts +30 -0
- package/dist/daemon/fuse-engine.js +118 -0
- package/dist/daemon/launchagent.d.ts +7 -0
- package/dist/daemon/launchagent.js +49 -0
- package/dist/daemon/lock-manager.d.ts +16 -0
- package/dist/daemon/lock-manager.js +71 -0
- package/dist/daemon/metrics.d.ts +20 -0
- package/dist/daemon/metrics.js +72 -0
- package/dist/daemon/server.d.ts +33 -0
- package/dist/daemon/server.js +283 -0
- package/dist/daemon/session-tracker.d.ts +28 -0
- package/dist/daemon/session-tracker.js +121 -0
- package/dist/daemon/state.d.ts +61 -0
- package/dist/daemon/state.js +126 -0
- package/dist/daemon/supervisor.d.ts +24 -0
- package/dist/daemon/supervisor.js +79 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +39 -0
- package/dist/merge.d.ts +24 -0
- package/dist/merge.js +65 -0
- package/dist/migration/migrate-locks.d.ts +5 -0
- package/dist/migration/migrate-locks.js +41 -0
- package/dist/worktree.d.ts +24 -0
- package/dist/worktree.js +65 -0
- package/package.json +60 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const DEFAULT_SOCK_PATH = path.join(os.homedir(), ".agentctl", "agentctl.sock");
|
|
6
|
+
export class DaemonClient {
|
|
7
|
+
sockPath;
|
|
8
|
+
constructor(sockPath) {
|
|
9
|
+
this.sockPath = sockPath || DEFAULT_SOCK_PATH;
|
|
10
|
+
}
|
|
11
|
+
async call(method, params) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const socket = net.createConnection(this.sockPath);
|
|
14
|
+
const id = crypto.randomUUID();
|
|
15
|
+
let buffer = "";
|
|
16
|
+
let settled = false;
|
|
17
|
+
const timeout = setTimeout(() => {
|
|
18
|
+
if (!settled) {
|
|
19
|
+
settled = true;
|
|
20
|
+
socket.destroy();
|
|
21
|
+
reject(new Error("Daemon request timed out"));
|
|
22
|
+
}
|
|
23
|
+
}, 30_000);
|
|
24
|
+
socket.on("connect", () => {
|
|
25
|
+
socket.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
26
|
+
});
|
|
27
|
+
socket.on("data", (chunk) => {
|
|
28
|
+
buffer += chunk.toString();
|
|
29
|
+
const lines = buffer.split("\n");
|
|
30
|
+
buffer = lines.pop() ?? "";
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
if (!line)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
const msg = JSON.parse(line);
|
|
36
|
+
if (msg.id === id && !msg.stream) {
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
if (msg.error)
|
|
40
|
+
reject(new Error(msg.error.message));
|
|
41
|
+
else
|
|
42
|
+
resolve(msg.result);
|
|
43
|
+
socket.end();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Malformed response
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
socket.on("error", (err) => {
|
|
52
|
+
if (settled)
|
|
53
|
+
return;
|
|
54
|
+
settled = true;
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
|
|
57
|
+
reject(new Error("Daemon not running. Start with: agentctl daemon start"));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
reject(err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
socket.on("close", () => {
|
|
64
|
+
if (!settled) {
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
reject(new Error("Connection closed before response received"));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async isRunning() {
|
|
73
|
+
try {
|
|
74
|
+
await this.call("daemon.status");
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
console.error("\u26a0\ufe0f agent-ctl is renamed to agentctl. Please update your scripts.");
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const agentctlBin = path.join(path.dirname(__filename), "cli.js");
|
|
8
|
+
try {
|
|
9
|
+
execFileSync(process.execPath, [agentctlBin, ...process.argv.slice(2)], {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
process.exit(err.status ?? 1);
|
|
15
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface AgentAdapter {
|
|
2
|
+
id: string;
|
|
3
|
+
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
4
|
+
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
5
|
+
status(sessionId: string): Promise<AgentSession>;
|
|
6
|
+
launch(opts: LaunchOpts): Promise<AgentSession>;
|
|
7
|
+
stop(sessionId: string, opts?: StopOpts): Promise<void>;
|
|
8
|
+
resume(sessionId: string, message: string): Promise<void>;
|
|
9
|
+
events(): AsyncIterable<LifecycleEvent>;
|
|
10
|
+
}
|
|
11
|
+
export interface AgentSession {
|
|
12
|
+
id: string;
|
|
13
|
+
adapter: string;
|
|
14
|
+
status: "running" | "idle" | "stopped" | "error";
|
|
15
|
+
startedAt: Date;
|
|
16
|
+
stoppedAt?: Date;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
spec?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
prompt?: string;
|
|
21
|
+
tokens?: {
|
|
22
|
+
in: number;
|
|
23
|
+
out: number;
|
|
24
|
+
};
|
|
25
|
+
cost?: number;
|
|
26
|
+
pid?: number;
|
|
27
|
+
meta: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface LifecycleEvent {
|
|
30
|
+
type: "session.started" | "session.stopped" | "session.idle" | "session.error";
|
|
31
|
+
adapter: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
session: AgentSession;
|
|
34
|
+
timestamp: Date;
|
|
35
|
+
meta?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface ListOpts {
|
|
38
|
+
status?: "running" | "idle" | "stopped" | "error";
|
|
39
|
+
all?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface PeekOpts {
|
|
42
|
+
lines?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface StopOpts {
|
|
45
|
+
force?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface LaunchOpts {
|
|
48
|
+
adapter: string;
|
|
49
|
+
prompt: string;
|
|
50
|
+
spec?: string;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
model?: string;
|
|
53
|
+
env?: Record<string, string>;
|
|
54
|
+
adapterOpts?: Record<string, unknown>;
|
|
55
|
+
/** Git worktree options — auto-create worktree before launch */
|
|
56
|
+
worktree?: {
|
|
57
|
+
repo: string;
|
|
58
|
+
branch: string;
|
|
59
|
+
};
|
|
60
|
+
/** Lifecycle hooks — shell commands to run at various points */
|
|
61
|
+
hooks?: LifecycleHooks;
|
|
62
|
+
}
|
|
63
|
+
export interface LifecycleHooks {
|
|
64
|
+
onCreate?: string;
|
|
65
|
+
onComplete?: string;
|
|
66
|
+
preMerge?: string;
|
|
67
|
+
postMerge?: string;
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { FuseTimer, SessionRecord, StateManager } from "./state.js";
|
|
3
|
+
export interface FuseEngineOpts {
|
|
4
|
+
defaultDurationMs: number;
|
|
5
|
+
emitter?: EventEmitter;
|
|
6
|
+
}
|
|
7
|
+
export declare class FuseEngine {
|
|
8
|
+
private timers;
|
|
9
|
+
private state;
|
|
10
|
+
private defaultDurationMs;
|
|
11
|
+
private emitter?;
|
|
12
|
+
constructor(state: StateManager, opts: FuseEngineOpts);
|
|
13
|
+
/** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
|
|
14
|
+
static deriveClusterName(directory: string): {
|
|
15
|
+
clusterName: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
} | null;
|
|
18
|
+
/** Called when a session exits. Starts fuse if applicable. */
|
|
19
|
+
onSessionExit(session: SessionRecord): void;
|
|
20
|
+
private startFuse;
|
|
21
|
+
/** Cancel fuse for a directory. */
|
|
22
|
+
cancelFuse(directory: string, persist?: boolean): boolean;
|
|
23
|
+
/** Resume fuses from persisted state after daemon restart. */
|
|
24
|
+
resumeTimers(): void;
|
|
25
|
+
/** Fire a fuse — delete the Kind cluster. */
|
|
26
|
+
private fireFuse;
|
|
27
|
+
listActive(): FuseTimer[];
|
|
28
|
+
/** Clear all timers (for clean shutdown) */
|
|
29
|
+
shutdown(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
export class FuseEngine {
|
|
7
|
+
timers = new Map();
|
|
8
|
+
state;
|
|
9
|
+
defaultDurationMs;
|
|
10
|
+
emitter;
|
|
11
|
+
constructor(state, opts) {
|
|
12
|
+
this.state = state;
|
|
13
|
+
this.defaultDurationMs = opts.defaultDurationMs;
|
|
14
|
+
this.emitter = opts.emitter;
|
|
15
|
+
}
|
|
16
|
+
/** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
|
|
17
|
+
static deriveClusterName(directory) {
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
const monoPrefix = path.join(home, "code", "mono-");
|
|
20
|
+
if (!directory.startsWith(monoPrefix))
|
|
21
|
+
return null;
|
|
22
|
+
const branch = directory.slice(monoPrefix.length);
|
|
23
|
+
if (!branch)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
clusterName: `kindo-charlie-${branch}`,
|
|
27
|
+
branch,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Called when a session exits. Starts fuse if applicable. */
|
|
31
|
+
onSessionExit(session) {
|
|
32
|
+
if (!session.cwd)
|
|
33
|
+
return;
|
|
34
|
+
const derived = FuseEngine.deriveClusterName(session.cwd);
|
|
35
|
+
if (!derived)
|
|
36
|
+
return;
|
|
37
|
+
this.startFuse(session.cwd, derived.clusterName, derived.branch, session.id);
|
|
38
|
+
}
|
|
39
|
+
startFuse(directory, clusterName, branch, sessionId) {
|
|
40
|
+
// Cancel existing fuse for same directory
|
|
41
|
+
this.cancelFuse(directory, false);
|
|
42
|
+
const expiresAt = new Date(Date.now() + this.defaultDurationMs);
|
|
43
|
+
const fuse = {
|
|
44
|
+
directory,
|
|
45
|
+
clusterName,
|
|
46
|
+
branch,
|
|
47
|
+
expiresAt: expiresAt.toISOString(),
|
|
48
|
+
sessionId,
|
|
49
|
+
};
|
|
50
|
+
this.state.addFuse(fuse);
|
|
51
|
+
const timeout = setTimeout(() => this.fireFuse(fuse), this.defaultDurationMs);
|
|
52
|
+
this.timers.set(directory, timeout);
|
|
53
|
+
}
|
|
54
|
+
/** Cancel fuse for a directory. */
|
|
55
|
+
cancelFuse(directory, persist = true) {
|
|
56
|
+
const timer = this.timers.get(directory);
|
|
57
|
+
if (timer) {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
this.timers.delete(directory);
|
|
60
|
+
}
|
|
61
|
+
if (persist) {
|
|
62
|
+
this.state.removeFuse(directory);
|
|
63
|
+
}
|
|
64
|
+
return !!timer;
|
|
65
|
+
}
|
|
66
|
+
/** Resume fuses from persisted state after daemon restart. */
|
|
67
|
+
resumeTimers() {
|
|
68
|
+
const fuses = this.state.getFuses();
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const fuse of fuses) {
|
|
71
|
+
const remaining = new Date(fuse.expiresAt).getTime() - now;
|
|
72
|
+
if (remaining <= 0) {
|
|
73
|
+
// Expired while daemon was down — fire immediately
|
|
74
|
+
this.fireFuse(fuse);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const timeout = setTimeout(() => this.fireFuse(fuse), remaining);
|
|
78
|
+
this.timers.set(fuse.directory, timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Fire a fuse — delete the Kind cluster. */
|
|
83
|
+
async fireFuse(fuse) {
|
|
84
|
+
this.timers.delete(fuse.directory);
|
|
85
|
+
this.state.removeFuse(fuse.directory);
|
|
86
|
+
console.log(`Fuse fired: deleting cluster ${fuse.clusterName}`);
|
|
87
|
+
try {
|
|
88
|
+
// Best effort: yarn local:down first
|
|
89
|
+
try {
|
|
90
|
+
await execAsync("yarn local:down", {
|
|
91
|
+
cwd: fuse.directory,
|
|
92
|
+
timeout: 60_000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Ignore
|
|
97
|
+
}
|
|
98
|
+
await execAsync(`kind delete cluster --name ${fuse.clusterName}`, {
|
|
99
|
+
timeout: 120_000,
|
|
100
|
+
});
|
|
101
|
+
console.log(`Cluster ${fuse.clusterName} deleted`);
|
|
102
|
+
this.emitter?.emit("fuse.fired", fuse);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.error(`Failed to delete cluster ${fuse.clusterName}:`, err);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
listActive() {
|
|
109
|
+
return this.state.getFuses();
|
|
110
|
+
}
|
|
111
|
+
/** Clear all timers (for clean shutdown) */
|
|
112
|
+
shutdown() {
|
|
113
|
+
for (const timer of this.timers.values()) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
this.timers.clear();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Generate a LaunchAgent plist with dynamically resolved paths.
|
|
6
|
+
*/
|
|
7
|
+
export function generatePlist(opts) {
|
|
8
|
+
const home = os.homedir();
|
|
9
|
+
const nodePath = opts?.nodePath || process.execPath;
|
|
10
|
+
const cliPath = opts?.cliPath ||
|
|
11
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
12
|
+
// Normalize to absolute
|
|
13
|
+
const resolvedCliPath = path.resolve(cliPath);
|
|
14
|
+
// Get PATH including node's bin dir
|
|
15
|
+
const nodeBinDir = path.dirname(nodePath);
|
|
16
|
+
const envPath = `/usr/local/bin:/usr/bin:/bin:${nodeBinDir}`;
|
|
17
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
18
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
19
|
+
<plist version="1.0">
|
|
20
|
+
<dict>
|
|
21
|
+
<key>Label</key>
|
|
22
|
+
<string>com.agentctl.daemon</string>
|
|
23
|
+
<key>ProgramArguments</key>
|
|
24
|
+
<array>
|
|
25
|
+
<string>${nodePath}</string>
|
|
26
|
+
<string>${resolvedCliPath}</string>
|
|
27
|
+
<string>daemon</string>
|
|
28
|
+
<string>start</string>
|
|
29
|
+
<string>--foreground</string>
|
|
30
|
+
</array>
|
|
31
|
+
<key>RunAtLoad</key>
|
|
32
|
+
<true/>
|
|
33
|
+
<key>KeepAlive</key>
|
|
34
|
+
<true/>
|
|
35
|
+
<key>StandardOutPath</key>
|
|
36
|
+
<string>${home}/.agentctl/daemon.stdout.log</string>
|
|
37
|
+
<key>StandardErrorPath</key>
|
|
38
|
+
<string>${home}/.agentctl/daemon.stderr.log</string>
|
|
39
|
+
<key>EnvironmentVariables</key>
|
|
40
|
+
<dict>
|
|
41
|
+
<key>PATH</key>
|
|
42
|
+
<string>${envPath}</string>
|
|
43
|
+
<key>HOME</key>
|
|
44
|
+
<string>${home}</string>
|
|
45
|
+
</dict>
|
|
46
|
+
</dict>
|
|
47
|
+
</plist>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Lock, StateManager } from "./state.js";
|
|
2
|
+
export declare class LockManager {
|
|
3
|
+
private state;
|
|
4
|
+
constructor(state: StateManager);
|
|
5
|
+
/** Check if directory is locked. Returns the lock if so, null if free. */
|
|
6
|
+
check(directory: string): Lock | null;
|
|
7
|
+
/** Auto-lock a directory for a session. Idempotent if same session. */
|
|
8
|
+
autoLock(directory: string, sessionId: string): Lock;
|
|
9
|
+
/** Remove auto-lock for a session. */
|
|
10
|
+
autoUnlock(sessionId: string): void;
|
|
11
|
+
/** Manual lock. Fails if already manually locked. */
|
|
12
|
+
manualLock(directory: string, by?: string, reason?: string): Lock;
|
|
13
|
+
/** Manual unlock. Only removes manual locks. */
|
|
14
|
+
manualUnlock(directory: string): void;
|
|
15
|
+
listAll(): Lock[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export class LockManager {
|
|
3
|
+
state;
|
|
4
|
+
constructor(state) {
|
|
5
|
+
this.state = state;
|
|
6
|
+
}
|
|
7
|
+
/** Check if directory is locked. Returns the lock if so, null if free. */
|
|
8
|
+
check(directory) {
|
|
9
|
+
const resolved = path.resolve(directory);
|
|
10
|
+
const locks = this.state.getLocks();
|
|
11
|
+
// Manual locks take precedence
|
|
12
|
+
const manual = locks.find((l) => l.directory === resolved && l.type === "manual");
|
|
13
|
+
if (manual)
|
|
14
|
+
return manual;
|
|
15
|
+
const auto = locks.find((l) => l.directory === resolved && l.type === "auto");
|
|
16
|
+
return auto || null;
|
|
17
|
+
}
|
|
18
|
+
/** Auto-lock a directory for a session. Idempotent if same session. */
|
|
19
|
+
autoLock(directory, sessionId) {
|
|
20
|
+
const resolved = path.resolve(directory);
|
|
21
|
+
const existing = this.state
|
|
22
|
+
.getLocks()
|
|
23
|
+
.find((l) => l.directory === resolved &&
|
|
24
|
+
l.type === "auto" &&
|
|
25
|
+
l.sessionId === sessionId);
|
|
26
|
+
if (existing)
|
|
27
|
+
return existing;
|
|
28
|
+
const lock = {
|
|
29
|
+
directory: resolved,
|
|
30
|
+
type: "auto",
|
|
31
|
+
sessionId,
|
|
32
|
+
lockedAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
this.state.addLock(lock);
|
|
35
|
+
return lock;
|
|
36
|
+
}
|
|
37
|
+
/** Remove auto-lock for a session. */
|
|
38
|
+
autoUnlock(sessionId) {
|
|
39
|
+
this.state.removeLocks((l) => l.type === "auto" && l.sessionId === sessionId);
|
|
40
|
+
}
|
|
41
|
+
/** Manual lock. Fails if already manually locked. */
|
|
42
|
+
manualLock(directory, by, reason) {
|
|
43
|
+
const resolved = path.resolve(directory);
|
|
44
|
+
const existing = this.check(resolved);
|
|
45
|
+
if (existing?.type === "manual") {
|
|
46
|
+
throw new Error(`Already manually locked by ${existing.lockedBy}: ${existing.reason}`);
|
|
47
|
+
}
|
|
48
|
+
const lock = {
|
|
49
|
+
directory: resolved,
|
|
50
|
+
type: "manual",
|
|
51
|
+
lockedBy: by,
|
|
52
|
+
reason,
|
|
53
|
+
lockedAt: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
this.state.addLock(lock);
|
|
56
|
+
return lock;
|
|
57
|
+
}
|
|
58
|
+
/** Manual unlock. Only removes manual locks. */
|
|
59
|
+
manualUnlock(directory) {
|
|
60
|
+
const resolved = path.resolve(directory);
|
|
61
|
+
const existing = this.state
|
|
62
|
+
.getLocks()
|
|
63
|
+
.find((l) => l.directory === resolved && l.type === "manual");
|
|
64
|
+
if (!existing)
|
|
65
|
+
throw new Error(`No manual lock on ${resolved}`);
|
|
66
|
+
this.state.removeLocks((l) => l.directory === resolved && l.type === "manual");
|
|
67
|
+
}
|
|
68
|
+
listAll() {
|
|
69
|
+
return this.state.getLocks();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FuseEngine } from "./fuse-engine.js";
|
|
2
|
+
import type { LockManager } from "./lock-manager.js";
|
|
3
|
+
import type { SessionTracker } from "./session-tracker.js";
|
|
4
|
+
export declare class MetricsRegistry {
|
|
5
|
+
private sessionTracker;
|
|
6
|
+
private lockManager;
|
|
7
|
+
private fuseEngine;
|
|
8
|
+
sessionsTotalCompleted: number;
|
|
9
|
+
sessionsTotalFailed: number;
|
|
10
|
+
sessionsTotalStopped: number;
|
|
11
|
+
fusesFiredTotal: number;
|
|
12
|
+
clustersDeletedTotal: number;
|
|
13
|
+
sessionDurations: number[];
|
|
14
|
+
constructor(sessionTracker: SessionTracker, lockManager: LockManager, fuseEngine: FuseEngine);
|
|
15
|
+
recordSessionCompleted(durationSeconds?: number): void;
|
|
16
|
+
recordSessionFailed(durationSeconds?: number): void;
|
|
17
|
+
recordSessionStopped(durationSeconds?: number): void;
|
|
18
|
+
recordFuseFired(): void;
|
|
19
|
+
generateMetrics(): string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export class MetricsRegistry {
|
|
2
|
+
sessionTracker;
|
|
3
|
+
lockManager;
|
|
4
|
+
fuseEngine;
|
|
5
|
+
sessionsTotalCompleted = 0;
|
|
6
|
+
sessionsTotalFailed = 0;
|
|
7
|
+
sessionsTotalStopped = 0;
|
|
8
|
+
fusesFiredTotal = 0;
|
|
9
|
+
clustersDeletedTotal = 0;
|
|
10
|
+
sessionDurations = []; // seconds
|
|
11
|
+
constructor(sessionTracker, lockManager, fuseEngine) {
|
|
12
|
+
this.sessionTracker = sessionTracker;
|
|
13
|
+
this.lockManager = lockManager;
|
|
14
|
+
this.fuseEngine = fuseEngine;
|
|
15
|
+
}
|
|
16
|
+
recordSessionCompleted(durationSeconds) {
|
|
17
|
+
this.sessionsTotalCompleted++;
|
|
18
|
+
if (durationSeconds != null)
|
|
19
|
+
this.sessionDurations.push(durationSeconds);
|
|
20
|
+
}
|
|
21
|
+
recordSessionFailed(durationSeconds) {
|
|
22
|
+
this.sessionsTotalFailed++;
|
|
23
|
+
if (durationSeconds != null)
|
|
24
|
+
this.sessionDurations.push(durationSeconds);
|
|
25
|
+
}
|
|
26
|
+
recordSessionStopped(durationSeconds) {
|
|
27
|
+
this.sessionsTotalStopped++;
|
|
28
|
+
if (durationSeconds != null)
|
|
29
|
+
this.sessionDurations.push(durationSeconds);
|
|
30
|
+
}
|
|
31
|
+
recordFuseFired() {
|
|
32
|
+
this.fusesFiredTotal++;
|
|
33
|
+
this.clustersDeletedTotal++;
|
|
34
|
+
}
|
|
35
|
+
generateMetrics() {
|
|
36
|
+
const lines = [];
|
|
37
|
+
const g = (name, help, value, labels) => {
|
|
38
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
39
|
+
lines.push(`# TYPE ${name} gauge`);
|
|
40
|
+
lines.push(labels ? `${name}{${labels}} ${value}` : `${name} ${value}`);
|
|
41
|
+
};
|
|
42
|
+
const c = (name, help, value, labels) => {
|
|
43
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
44
|
+
lines.push(`# TYPE ${name} counter`);
|
|
45
|
+
lines.push(labels ? `${name}{${labels}} ${value}` : `${name} ${value}`);
|
|
46
|
+
};
|
|
47
|
+
// Gauges
|
|
48
|
+
g("agentctl_sessions_active", "Number of active sessions", this.sessionTracker.activeCount());
|
|
49
|
+
const locks = this.lockManager.listAll();
|
|
50
|
+
g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "auto").length, 'type="auto"');
|
|
51
|
+
g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "manual").length, 'type="manual"');
|
|
52
|
+
g("agentctl_fuses_active", "Number of active fuse timers", this.fuseEngine.listActive().length);
|
|
53
|
+
// Counters
|
|
54
|
+
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalCompleted, 'status="completed"');
|
|
55
|
+
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalFailed, 'status="failed"');
|
|
56
|
+
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalStopped, 'status="stopped"');
|
|
57
|
+
c("agentctl_fuses_fired_total", "Total fuses fired", this.fusesFiredTotal);
|
|
58
|
+
c("agentctl_kind_clusters_deleted_total", "Total Kind clusters deleted", this.clustersDeletedTotal);
|
|
59
|
+
// Histogram (session duration)
|
|
60
|
+
lines.push("# HELP agentctl_session_duration_seconds Session duration histogram");
|
|
61
|
+
lines.push("# TYPE agentctl_session_duration_seconds histogram");
|
|
62
|
+
const buckets = [60, 300, 600, 1800, 3600, 7200, Number.POSITIVE_INFINITY];
|
|
63
|
+
for (const b of buckets) {
|
|
64
|
+
const count = this.sessionDurations.filter((d) => d <= b).length;
|
|
65
|
+
const label = b === Number.POSITIVE_INFINITY ? "+Inf" : String(b);
|
|
66
|
+
lines.push(`agentctl_session_duration_seconds_bucket{le="${label}"} ${count}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push(`agentctl_session_duration_seconds_sum ${this.sessionDurations.reduce((a, b) => a + b, 0)}`);
|
|
69
|
+
lines.push(`agentctl_session_duration_seconds_count ${this.sessionDurations.length}`);
|
|
70
|
+
return `${lines.join("\n")}\n`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import type { AgentAdapter } from "../core/types.js";
|
|
4
|
+
export interface DaemonRequest {
|
|
5
|
+
id: string;
|
|
6
|
+
method: string;
|
|
7
|
+
params?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface DaemonResponse {
|
|
10
|
+
id: string;
|
|
11
|
+
result?: unknown;
|
|
12
|
+
error?: {
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface DaemonStatus {
|
|
18
|
+
pid: number;
|
|
19
|
+
uptime: number;
|
|
20
|
+
sessions: number;
|
|
21
|
+
locks: number;
|
|
22
|
+
fuses: number;
|
|
23
|
+
}
|
|
24
|
+
export interface DaemonStartOpts {
|
|
25
|
+
metricsPort?: number;
|
|
26
|
+
configDir?: string;
|
|
27
|
+
adapters?: Record<string, AgentAdapter>;
|
|
28
|
+
}
|
|
29
|
+
export declare function startDaemon(opts?: DaemonStartOpts): Promise<{
|
|
30
|
+
socketServer: net.Server;
|
|
31
|
+
httpServer: http.Server;
|
|
32
|
+
shutdown: () => Promise<void>;
|
|
33
|
+
}>;
|