feat: emit job-state events from rpc
This commit is contained in:
@@ -14,6 +14,12 @@ actor AgentRPC {
|
|||||||
let reason: String?
|
let reason: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct JobStateEvent: Codable {
|
||||||
|
let id: String
|
||||||
|
let state: String
|
||||||
|
let durationMs: Double?
|
||||||
|
}
|
||||||
|
|
||||||
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
|
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
|
||||||
|
|
||||||
private var process: Process?
|
private var process: Process?
|
||||||
@@ -23,6 +29,7 @@ actor AgentRPC {
|
|||||||
private var waiters: [CheckedContinuation<String, Error>] = []
|
private var waiters: [CheckedContinuation<String, Error>] = []
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
|
||||||
private var starting = false
|
private var starting = false
|
||||||
|
private var activeJobs = 0
|
||||||
|
|
||||||
private struct RpcError: Error { let message: String }
|
private struct RpcError: Error { let message: String }
|
||||||
|
|
||||||
@@ -196,6 +203,10 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if let jobEvent = self.parseJobStateEvent(from: line) {
|
||||||
|
Task { await self.updateJobState(jobEvent) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let waiter = waiters.first {
|
if let waiter = waiters.first {
|
||||||
self.waiters.removeFirst()
|
self.waiters.removeFirst()
|
||||||
@@ -204,6 +215,21 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateJobState(_ evt: JobStateEvent) async {
|
||||||
|
switch evt.state.lowercased() {
|
||||||
|
case "started", "streaming":
|
||||||
|
self.activeJobs &+= 1
|
||||||
|
case "done", "error":
|
||||||
|
self.activeJobs = max(0, self.activeJobs - 1)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let working = self.activeJobs > 0
|
||||||
|
await MainActor.run {
|
||||||
|
AppStateStore.shared.setWorking(working)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func parseHeartbeatEvent(from line: String) -> HeartbeatEvent? {
|
private func parseHeartbeatEvent(from line: String) -> HeartbeatEvent? {
|
||||||
guard let data = line.data(using: .utf8) else { return nil }
|
guard let data = line.data(using: .utf8) else { return nil }
|
||||||
guard
|
guard
|
||||||
@@ -222,6 +248,24 @@ actor AgentRPC {
|
|||||||
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
|
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func parseJobStateEvent(from line: String) -> JobStateEvent? {
|
||||||
|
guard let data = line.data(using: .utf8) else { return nil }
|
||||||
|
guard
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let type = obj["type"] as? String,
|
||||||
|
type == "event",
|
||||||
|
let evt = obj["event"] as? String,
|
||||||
|
evt == "job-state",
|
||||||
|
let payload = obj["payload"] as? [String: Any]
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||||
|
return try? decoder.decode(JobStateEvent.self, from: payloadData)
|
||||||
|
}
|
||||||
|
|
||||||
private func nextLine() async throws -> String {
|
private func nextLine() async throws -> String {
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||||
self.waiters.append(cont)
|
self.waiters.append(cont)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
@@ -272,6 +273,14 @@ Examples:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jobId = cmd.jobId ? String(cmd.jobId) : randomUUID();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
respond({
|
||||||
|
type: "event",
|
||||||
|
event: "job-state",
|
||||||
|
payload: { id: jobId, state: "started", startedAt },
|
||||||
|
});
|
||||||
|
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: (msg: string) => logs.push(String(msg)),
|
log: (msg: string) => logs.push(String(msg)),
|
||||||
@@ -299,9 +308,31 @@ Examples:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await agentCommand(opts, runtime, createDefaultDeps());
|
await agentCommand(opts, runtime, createDefaultDeps());
|
||||||
|
const endedAt = Date.now();
|
||||||
|
respond({
|
||||||
|
type: "event",
|
||||||
|
event: "job-state",
|
||||||
|
payload: {
|
||||||
|
id: jobId,
|
||||||
|
state: "done",
|
||||||
|
durationMs: endedAt - startedAt,
|
||||||
|
endedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
const payload = extractPayload(logs);
|
const payload = extractPayload(logs);
|
||||||
respond({ type: "result", ok: true, payload });
|
respond({ type: "result", ok: true, payload });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const endedAt = Date.now();
|
||||||
|
respond({
|
||||||
|
type: "event",
|
||||||
|
event: "job-state",
|
||||||
|
payload: {
|
||||||
|
id: jobId,
|
||||||
|
state: "error",
|
||||||
|
durationMs: endedAt - startedAt,
|
||||||
|
endedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
respond({ type: "error", error: String(err) });
|
respond({ type: "error", error: String(err) });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -50,11 +50,18 @@ describe("control channel", () => {
|
|||||||
const frame = { type: "request", id, method, params };
|
const frame = { type: "request", id, method, params };
|
||||||
client.write(`${JSON.stringify(frame)}\n`);
|
client.write(`${JSON.stringify(frame)}\n`);
|
||||||
const onData = (chunk: Buffer) => {
|
const onData = (chunk: Buffer) => {
|
||||||
const line = chunk.toString("utf8").trim();
|
const lines = chunk.toString("utf8").trim().split(/\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(line) as { id?: string };
|
const parsed = JSON.parse(line) as { id?: string };
|
||||||
if (parsed.id === id) {
|
if (parsed.id === id) {
|
||||||
client.off("data", onData);
|
client.off("data", onData);
|
||||||
resolve(parsed as Record<string, unknown>);
|
resolve(parsed as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON noise */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
client.on("data", onData);
|
client.on("data", onData);
|
||||||
|
|||||||
Reference in New Issue
Block a user