@fickydev/pigent 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -34,6 +34,11 @@
34
34
  - Added short `pigent` command shim for `status`, `logs`, `start`, `stop`, `restart`, `update`, and `setup`.
35
35
  - Simplified published CLI flow so `bunx @fickydev/pigent` runs setup and starts Pigent without install/start/typecheck prompts.
36
36
  - Added automatic database migrations at daemon startup.
37
+ - Added `HeartbeatRepository` for creating, updating, and querying heartbeat runs.
38
+ - Added `AgentRegistry` to centralize loaded agent/profile lookup and agent DB sync.
39
+ - Added safe Pi runner failure replies with internal error persistence.
40
+ - Added per-session locking to serialize Pi runs for the same agent/channel/chat/thread.
41
+ - Added Telegram chat instructions to Pi prompt composition.
37
42
  - Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
38
43
 
39
44
  ### Changed
package/TODO.md CHANGED
@@ -66,12 +66,12 @@
66
66
  - [x] Define `messages` table
67
67
  - [x] Define `heartbeats` table
68
68
  - [x] Define `runtime_kv` table for offsets and daemon state
69
- - [ ] Implement repositories
69
+ - [x] Implement repositories
70
70
  - [x] `AgentRepository`
71
71
  - [x] `SessionRepository`
72
72
  - [x] `MessageRepository`
73
73
  - [x] `TelegramRepository`
74
- - [ ] `HeartbeatRepository`
74
+ - [x] `HeartbeatRepository`
75
75
 
76
76
  ## Channel Layer
77
77
 
@@ -106,18 +106,18 @@
106
106
 
107
107
  ## Agent Runtime
108
108
 
109
- - [ ] Implement `AgentRegistry`
109
+ - [x] Implement `AgentRegistry`
110
110
  - [x] Implement `AgentDaemon`
111
111
  - [x] Implement `AgentRunner`
112
112
  - [ ] Implement `PiSessionFactory`
113
113
  - [x] Implement `PiAgentRunner`
114
114
  - [x] Create/get session by `agentId + channel + chatId + threadId`
115
- - [ ] Compose prompt with chat instructions
115
+ - [x] Compose prompt with chat instructions
116
116
  - [x] Compose basic channel/chat/user prompt for Pi runner
117
117
  - [x] Persist inbound message before Pi run
118
118
  - [x] Persist outbound response after Pi run
119
- - [ ] Handle Pi errors and send safe error reply
120
- - [ ] Add per-session lock to prevent concurrent Pi runs
119
+ - [x] Handle Pi errors and send safe error reply
120
+ - [x] Add per-session lock to prevent concurrent Pi runs
121
121
 
122
122
  ## Pi Integration
123
123
 
@@ -136,7 +136,7 @@
136
136
  - [ ] Add per-agent heartbeat lock
137
137
  - [ ] Add heartbeat prompt composition
138
138
  - [ ] Add `NOOP` handling
139
- - [ ] Persist heartbeat start/result/failure
139
+ - [x] Persist heartbeat start/result/failure
140
140
  - [ ] Add notification cooldown
141
141
  - [ ] Add max heartbeat messages per hour
142
142
  - [ ] Send heartbeat output to configured channel only when useful
@@ -156,7 +156,7 @@
156
156
  ## Commands UX
157
157
 
158
158
  - [x] `/agents` list agents available in chat
159
- - [ ] `/agent <id> <message>` route message
159
+ - [x] `/agent <id> <message>` route message
160
160
  - [ ] `/default-agent <id>` set chat default
161
161
  - [ ] `/instructions <text>` set chat instructions
162
162
  - [ ] `/sessions` list active sessions for chat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -0,0 +1,45 @@
1
+ import type { LoadedAgentConfig, LoadedConfig, ProfileConfig } from "../config/schemas";
2
+ import type { Repositories } from "../db/repositories";
3
+
4
+ export class AgentRegistry {
5
+ private readonly agentsById: Map<string, LoadedAgentConfig>;
6
+ private readonly profilesById: Map<string, ProfileConfig>;
7
+
8
+ constructor(
9
+ private readonly config: LoadedConfig,
10
+ private readonly repositories: Repositories,
11
+ ) {
12
+ this.agentsById = new Map(config.agents.map((agent) => [agent.id, agent]));
13
+ this.profilesById = new Map(config.profiles.map((profile) => [profile.id, profile]));
14
+ }
15
+
16
+ async syncConfiguredAgents(): Promise<void> {
17
+ for (const agent of this.listAgents()) {
18
+ await this.repositories.agents.upsertLoadedAgent(agent);
19
+ }
20
+ }
21
+
22
+ listAgents(): LoadedAgentConfig[] {
23
+ return [...this.agentsById.values()];
24
+ }
25
+
26
+ listProfiles(): ProfileConfig[] {
27
+ return [...this.profilesById.values()];
28
+ }
29
+
30
+ getAgent(id: string): LoadedAgentConfig | null {
31
+ return this.agentsById.get(id) ?? null;
32
+ }
33
+
34
+ getProfile(id: string): ProfileConfig | null {
35
+ return this.profilesById.get(id) ?? null;
36
+ }
37
+
38
+ hasAgent(id: string): boolean {
39
+ return this.agentsById.has(id);
40
+ }
41
+
42
+ defaultAgentId(): string | null {
43
+ return this.listAgents()[0]?.id ?? null;
44
+ }
45
+ }
@@ -1,8 +1,8 @@
1
1
  import type { InboundMessage } from "../channels/types";
2
- import type { LoadedAgentConfig, LoadedConfig, ProfileConfig } from "../config/schemas";
3
2
  import type { Repositories } from "../db/repositories";
4
3
  import { logger } from "../logging/logger";
5
4
  import { PiAgentRunner } from "../pi/PiAgentRunner";
5
+ import type { AgentRegistry } from "./AgentRegistry";
6
6
 
7
7
  export type AgentRunInput = {
8
8
  agentId: string;
@@ -10,15 +10,29 @@ export type AgentRunInput = {
10
10
  message: InboundMessage;
11
11
  };
12
12
 
13
+ type AgentRunResponse = {
14
+ text: string;
15
+ error?: string;
16
+ };
17
+
18
+ function sessionLockKey(input: AgentRunInput): string {
19
+ return [input.agentId, input.message.channel, input.message.chatId, input.message.threadId ?? ""].join(":");
20
+ }
21
+
13
22
  export class AgentRunner {
14
23
  private readonly piRunner = new PiAgentRunner();
24
+ private readonly sessionLocks = new Map<string, Promise<void>>();
15
25
 
16
26
  constructor(
17
- private readonly config: LoadedConfig,
27
+ private readonly registry: AgentRegistry,
18
28
  private readonly repositories: Repositories,
19
29
  ) {}
20
30
 
21
31
  async run(input: AgentRunInput): Promise<string> {
32
+ return this.withSessionLock(input, () => this.runUnlocked(input));
33
+ }
34
+
35
+ private async runUnlocked(input: AgentRunInput): Promise<string> {
22
36
  const session = await this.repositories.sessions.getOrCreate({
23
37
  agentId: input.agentId,
24
38
  channel: input.message.channel,
@@ -38,7 +52,20 @@ export class AgentRunner {
38
52
  rawJson: input.message.raw,
39
53
  });
40
54
 
41
- const response = await this.createResponse(input);
55
+ const chatInstructions = await this.chatInstructions(input);
56
+ const response = await this.createResponse(input, chatInstructions);
57
+
58
+ if (response.error) {
59
+ await this.repositories.messages.create({
60
+ agentId: input.agentId,
61
+ sessionId: session.id,
62
+ channel: input.message.channel,
63
+ direction: "internal",
64
+ chatId: input.message.chatId,
65
+ threadId: input.message.threadId,
66
+ content: response.error,
67
+ });
68
+ }
42
69
 
43
70
  await this.repositories.messages.create({
44
71
  agentId: input.agentId,
@@ -47,41 +74,65 @@ export class AgentRunner {
47
74
  direction: "outbound",
48
75
  chatId: input.message.chatId,
49
76
  threadId: input.message.threadId,
50
- content: response,
77
+ content: response.text,
51
78
  });
52
79
 
53
- return response;
80
+ return response.text;
54
81
  }
55
82
 
56
- private async createResponse(input: AgentRunInput): Promise<string> {
83
+ private async withSessionLock<T>(input: AgentRunInput, task: () => Promise<T>): Promise<T> {
84
+ const key = sessionLockKey(input);
85
+ const previous = this.sessionLocks.get(key) ?? Promise.resolve();
86
+ const run = previous.catch(() => undefined).then(task);
87
+ const tail = run.then(
88
+ () => undefined,
89
+ () => undefined,
90
+ );
91
+
92
+ this.sessionLocks.set(key, tail);
93
+
94
+ try {
95
+ return await run;
96
+ } finally {
97
+ if (this.sessionLocks.get(key) === tail) {
98
+ this.sessionLocks.delete(key);
99
+ }
100
+ }
101
+ }
102
+
103
+ private async createResponse(input: AgentRunInput, chatInstructions: string): Promise<AgentRunResponse> {
57
104
  if (process.env.PIGENT_FAKE_AGENT === "1") {
58
- return this.fakeResponse(input.agentId, input.text);
105
+ return { text: this.fakeResponse(input.agentId, input.text) };
59
106
  }
60
107
 
61
- const agent = this.findAgent(input.agentId);
62
- if (!agent) return `Unknown agent: ${input.agentId}`;
108
+ const agent = this.registry.getAgent(input.agentId);
109
+ if (!agent) return { text: `Unknown agent: ${input.agentId}` };
63
110
 
64
111
  try {
65
- return await this.piRunner.run({
112
+ const text = await this.piRunner.run({
66
113
  agent,
67
- profile: this.findProfile(agent.profile),
68
- prompt: this.composePrompt(input),
114
+ profile: this.registry.getProfile(agent.profile),
115
+ prompt: this.composePrompt(input, chatInstructions),
69
116
  });
117
+
118
+ return { text };
70
119
  } catch (error) {
120
+ const errorMessage = error instanceof Error ? error.message : String(error);
121
+
71
122
  logger.error("pi runner failed", {
72
123
  agentId: input.agentId,
73
- error: error instanceof Error ? error.message : String(error),
124
+ error: errorMessage,
74
125
  });
75
126
 
76
127
  if (process.env.PIGENT_FALLBACK_FAKE_AGENT === "1") {
77
- return this.fakeResponse(input.agentId, input.text);
128
+ return { text: this.fakeResponse(input.agentId, input.text), error: errorMessage };
78
129
  }
79
130
 
80
- return "Agent execution failed. Check daemon logs.";
131
+ return { text: "Agent failed to respond. Please try again later.", error: errorMessage };
81
132
  }
82
133
  }
83
134
 
84
- private composePrompt(input: AgentRunInput): string {
135
+ private composePrompt(input: AgentRunInput, chatInstructions: string): string {
85
136
  return [
86
137
  `[Channel]\n${input.message.channel}`,
87
138
  `[Chat]\n${input.message.chatId}`,
@@ -89,24 +140,24 @@ export class AgentRunner {
89
140
  input.message.senderName || input.message.senderId
90
141
  ? `[User]\n${input.message.senderName ?? input.message.senderId}`
91
142
  : null,
143
+ chatInstructions ? `[Chat Instructions]\n${chatInstructions}` : null,
92
144
  `[Message]\n${input.text}`,
93
145
  ]
94
146
  .filter(Boolean)
95
147
  .join("\n\n");
96
148
  }
97
149
 
98
- private fakeResponse(agentId: string, text: string): string {
99
- const agent = this.findAgent(agentId);
100
- const name = agent?.name ?? agentId;
150
+ private async chatInstructions(input: AgentRunInput): Promise<string> {
151
+ if (input.message.channel !== "telegram") return "";
101
152
 
102
- return `[${name}] fake runner received: ${text || "(empty message)"}`;
153
+ const chat = await this.repositories.telegram.findChat(input.message.chatId);
154
+ return chat?.instructions.trim() ?? "";
103
155
  }
104
156
 
105
- private findAgent(agentId: string): LoadedAgentConfig | null {
106
- return this.config.agents.find((candidate) => candidate.id === agentId) ?? null;
107
- }
157
+ private fakeResponse(agentId: string, text: string): string {
158
+ const agent = this.registry.getAgent(agentId);
159
+ const name = agent?.name ?? agentId;
108
160
 
109
- private findProfile(profileId: string): ProfileConfig | null {
110
- return this.config.profiles.find((candidate) => candidate.id === profileId) ?? null;
161
+ return `[${name}] fake runner received: ${text || "(empty message)"}`;
111
162
  }
112
163
  }
@@ -1,6 +1,6 @@
1
1
  import type { InboundMessage } from "../channels/types";
2
- import type { LoadedConfig } from "../config/schemas";
3
2
  import type { Repositories } from "../db/repositories";
3
+ import type { AgentRegistry } from "./AgentRegistry";
4
4
 
5
5
  export type BotCommandResult =
6
6
  | {
@@ -13,7 +13,7 @@ export type BotCommandResult =
13
13
 
14
14
  export class BotCommandHandler {
15
15
  constructor(
16
- private readonly config: LoadedConfig,
16
+ private readonly registry: AgentRegistry,
17
17
  private readonly repositories: Repositories,
18
18
  ) {}
19
19
 
@@ -48,7 +48,7 @@ export class BotCommandHandler {
48
48
  return "No agents configured for this chat.";
49
49
  }
50
50
 
51
- const allAgentIds = this.config.agents.map((agent) => agent.id);
51
+ const allAgentIds = this.registry.listAgents().map((agent) => agent.id);
52
52
  const allowedAgentIds = [];
53
53
 
54
54
  for (const agentId of allAgentIds) {
@@ -1,5 +1,6 @@
1
1
  import type { InboundMessage } from "../channels/types";
2
2
  import type { Repositories } from "../db/repositories";
3
+ import type { AgentRegistry } from "./AgentRegistry";
3
4
 
4
5
  export type RouteResult =
5
6
  | {
@@ -14,7 +15,10 @@ export type RouteResult =
14
15
  };
15
16
 
16
17
  export class MessageRouter {
17
- constructor(private readonly repositories: Repositories) {}
18
+ constructor(
19
+ private readonly repositories: Repositories,
20
+ private readonly registry: AgentRegistry,
21
+ ) {}
18
22
 
19
23
  async route(message: InboundMessage): Promise<RouteResult> {
20
24
  if (message.channel !== "telegram") {
@@ -54,8 +58,7 @@ export class MessageRouter {
54
58
  };
55
59
  }
56
60
 
57
- const agent = await this.repositories.agents.findById(agentId);
58
- if (!agent) {
61
+ if (!this.registry.hasAgent(agentId)) {
59
62
  return {
60
63
  ok: false,
61
64
  reason: "unknown_agent",
package/src/cli/run.ts CHANGED
@@ -63,12 +63,17 @@ async function main(): Promise<void> {
63
63
  await runForeground(resolveForegroundDir());
64
64
  return;
65
65
  default:
66
+ if (await isServiceActive()) {
67
+ console.log("Pigent already running.");
68
+ console.log("Commands: pigent status, pigent logs, pigent restart, pigent update");
69
+ process.exit(0);
70
+ }
66
71
  await installAndStartService(appDir);
67
72
  }
68
73
  }
69
74
 
70
75
  async function installAndStartService(targetDir: string): Promise<void> {
71
- await ensureConfiguredApp(targetDir, true);
76
+ await ensureConfiguredApp(targetDir, needsSetup(targetDir));
72
77
 
73
78
  if (!(await hasSystemctl())) {
74
79
  console.log("[pigent] systemd user service unavailable; running in foreground");
@@ -89,6 +94,7 @@ async function installAndStartService(targetDir: string): Promise<void> {
89
94
  console.log(" pigent stop");
90
95
  console.log(" pigent restart");
91
96
  console.log(" pigent update");
97
+ process.exit(0);
92
98
  }
93
99
 
94
100
  async function startService(targetDir: string): Promise<void> {
@@ -98,6 +104,7 @@ async function startService(targetDir: string): Promise<void> {
98
104
  }
99
105
 
100
106
  await systemctl("start");
107
+ process.exit(0);
101
108
  }
102
109
 
103
110
  async function updateApp(targetDir: string): Promise<void> {
@@ -117,6 +124,7 @@ async function updateApp(targetDir: string): Promise<void> {
117
124
  await systemctl("enable", true, `${serviceName}.service`);
118
125
  await systemctl("restart");
119
126
  console.log("[pigent] updated and restarted service");
127
+ process.exit(0);
120
128
  } else {
121
129
  console.log("[pigent] updated. Run `pigent run` to start foreground daemon.");
122
130
  }
@@ -259,6 +267,15 @@ async function hasSystemctl(): Promise<boolean> {
259
267
  return result.exitCode === 0;
260
268
  }
261
269
 
270
+ async function isServiceActive(): Promise<boolean> {
271
+ if (!(await hasSystemctl())) return false;
272
+ const proc = Bun.spawn(["systemctl", "--user", "is-active", "--quiet", `${serviceName}.service`], {
273
+ stdout: "ignore",
274
+ stderr: "ignore",
275
+ });
276
+ return (await proc.exited) === 0;
277
+ }
278
+
262
279
  function servicePath(): string {
263
280
  return join(homedir(), ".config", "systemd", "user", `${serviceName}.service`);
264
281
  }
@@ -1,3 +1,4 @@
1
+ import { AgentRegistry } from "../agents/AgentRegistry";
1
2
  import { AgentRunner } from "../agents/AgentRunner";
2
3
  import { BotCommandHandler } from "../agents/BotCommandHandler";
3
4
  import { MessageRouter } from "../agents/MessageRouter";
@@ -13,6 +14,7 @@ export class AgentDaemon {
13
14
  private running = false;
14
15
  private readonly adapters: ChannelAdapter[];
15
16
  private readonly commands: BotCommandHandler;
17
+ private readonly registry: AgentRegistry;
16
18
  private readonly router: MessageRouter;
17
19
  private readonly runner: AgentRunner;
18
20
 
@@ -21,9 +23,10 @@ export class AgentDaemon {
21
23
  private readonly repositories: Repositories,
22
24
  ) {
23
25
  this.adapters = createAdapters(repositories);
24
- this.commands = new BotCommandHandler(config, repositories);
25
- this.router = new MessageRouter(repositories);
26
- this.runner = new AgentRunner(config, repositories);
26
+ this.registry = new AgentRegistry(config, repositories);
27
+ this.commands = new BotCommandHandler(this.registry, repositories);
28
+ this.router = new MessageRouter(repositories, this.registry);
29
+ this.runner = new AgentRunner(this.registry, repositories);
27
30
  }
28
31
 
29
32
  static async create(): Promise<AgentDaemon> {
@@ -43,8 +46,8 @@ export class AgentDaemon {
43
46
  }
44
47
 
45
48
  logger.info("pigent daemon started", {
46
- agents: this.config.agents.length,
47
- profiles: this.config.profiles.length,
49
+ agents: this.registry.listAgents().length,
50
+ profiles: this.registry.listProfiles().length,
48
51
  telegramChats: this.config.telegramChats.length,
49
52
  adapters: this.adapters.map((adapter) => adapter.id),
50
53
  });
@@ -63,9 +66,7 @@ export class AgentDaemon {
63
66
  }
64
67
 
65
68
  private async syncConfiguredState(): Promise<void> {
66
- for (const agent of this.config.agents) {
67
- await this.repositories.agents.upsertLoadedAgent(agent);
68
- }
69
+ await this.registry.syncConfiguredAgents();
69
70
 
70
71
  for (const chat of this.config.telegramChats) {
71
72
  await this.repositories.telegram.upsertConfiguredChat(chat);
@@ -125,7 +126,7 @@ export class AgentDaemon {
125
126
  if (message.channel !== "telegram") return;
126
127
  if (process.env.PIGENT_AUTO_SETUP_CHATS !== "1") return;
127
128
 
128
- const defaultAgentId = process.env.PIGENT_AUTO_SETUP_DEFAULT_AGENT ?? this.config.agents[0]?.id;
129
+ const defaultAgentId = process.env.PIGENT_AUTO_SETUP_DEFAULT_AGENT ?? this.registry.defaultAgentId();
129
130
  if (!defaultAgentId) return;
130
131
 
131
132
  await this.repositories.telegram.autoConfigureChat(message, defaultAgentId);
@@ -0,0 +1,129 @@
1
+ import { and, desc, eq, gte, isNull } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { DbClient } from "../client";
4
+ import { heartbeats, type HeartbeatRow } from "../schema";
5
+
6
+ export type HeartbeatStatus = "pending" | "running" | "noop" | "notified" | "failed";
7
+
8
+ export type CreateHeartbeatInput = {
9
+ agentId: string;
10
+ sessionId?: string | null;
11
+ prompt: string;
12
+ status?: Extract<HeartbeatStatus, "pending" | "running">;
13
+ startedAt?: number | null;
14
+ };
15
+
16
+ export type FinishHeartbeatInput = {
17
+ result?: string | null;
18
+ error?: string | null;
19
+ finishedAt?: number;
20
+ };
21
+
22
+ export class HeartbeatRepository {
23
+ constructor(private readonly db: DbClient) {}
24
+
25
+ async create(input: CreateHeartbeatInput): Promise<HeartbeatRow> {
26
+ const id = nanoid();
27
+ const now = Date.now();
28
+
29
+ await this.db.insert(heartbeats).values({
30
+ id,
31
+ agentId: input.agentId,
32
+ sessionId: input.sessionId ?? null,
33
+ status: input.status ?? "pending",
34
+ prompt: input.prompt,
35
+ result: null,
36
+ error: null,
37
+ startedAt: input.startedAt ?? null,
38
+ finishedAt: null,
39
+ createdAt: now,
40
+ });
41
+
42
+ const row = await this.findById(id);
43
+ if (!row) throw new Error(`failed to create heartbeat ${id}`);
44
+
45
+ return row;
46
+ }
47
+
48
+ async markRunning(id: string, startedAt = Date.now()): Promise<HeartbeatRow> {
49
+ await this.db
50
+ .update(heartbeats)
51
+ .set({
52
+ status: "running",
53
+ startedAt,
54
+ })
55
+ .where(eq(heartbeats.id, id));
56
+
57
+ return this.requireById(id);
58
+ }
59
+
60
+ async markNoop(id: string, input: FinishHeartbeatInput = {}): Promise<HeartbeatRow> {
61
+ return this.finish(id, "noop", input);
62
+ }
63
+
64
+ async markNotified(id: string, input: FinishHeartbeatInput = {}): Promise<HeartbeatRow> {
65
+ return this.finish(id, "notified", input);
66
+ }
67
+
68
+ async markFailed(id: string, input: FinishHeartbeatInput): Promise<HeartbeatRow> {
69
+ return this.finish(id, "failed", input);
70
+ }
71
+
72
+ async findById(id: string): Promise<HeartbeatRow | null> {
73
+ const row = await this.db.query.heartbeats.findFirst({
74
+ where: eq(heartbeats.id, id),
75
+ });
76
+
77
+ return row ?? null;
78
+ }
79
+
80
+ async findLatest(agentId: string, sessionId?: string | null): Promise<HeartbeatRow | null> {
81
+ const sessionPredicate = sessionId ? eq(heartbeats.sessionId, sessionId) : isNull(heartbeats.sessionId);
82
+
83
+ const row = await this.db.query.heartbeats.findFirst({
84
+ where: and(eq(heartbeats.agentId, agentId), sessionPredicate),
85
+ orderBy: desc(heartbeats.createdAt),
86
+ });
87
+
88
+ return row ?? null;
89
+ }
90
+
91
+ async countRecentNotified(agentId: string, since: number, sessionId?: string | null): Promise<number> {
92
+ const sessionPredicate = sessionId ? eq(heartbeats.sessionId, sessionId) : isNull(heartbeats.sessionId);
93
+
94
+ const rows = await this.db.query.heartbeats.findMany({
95
+ where: and(
96
+ eq(heartbeats.agentId, agentId),
97
+ sessionPredicate,
98
+ eq(heartbeats.status, "notified"),
99
+ gte(heartbeats.finishedAt, since),
100
+ ),
101
+ columns: {
102
+ id: true,
103
+ },
104
+ });
105
+
106
+ return rows.length;
107
+ }
108
+
109
+ private async finish(id: string, status: Extract<HeartbeatStatus, "noop" | "notified" | "failed">, input: FinishHeartbeatInput): Promise<HeartbeatRow> {
110
+ await this.db
111
+ .update(heartbeats)
112
+ .set({
113
+ status,
114
+ result: input.result ?? null,
115
+ error: input.error ?? null,
116
+ finishedAt: input.finishedAt ?? Date.now(),
117
+ })
118
+ .where(eq(heartbeats.id, id));
119
+
120
+ return this.requireById(id);
121
+ }
122
+
123
+ private async requireById(id: string): Promise<HeartbeatRow> {
124
+ const row = await this.findById(id);
125
+ if (!row) throw new Error(`heartbeat not found ${id}`);
126
+
127
+ return row;
128
+ }
129
+ }
@@ -1,5 +1,6 @@
1
1
  import { db } from "../client";
2
2
  import { AgentRepository } from "./AgentRepository";
3
+ import { HeartbeatRepository } from "./HeartbeatRepository";
3
4
  import { MessageRepository } from "./MessageRepository";
4
5
  import { RuntimeKvRepository } from "./RuntimeKvRepository";
5
6
  import { SessionRepository } from "./SessionRepository";
@@ -10,6 +11,7 @@ export type Repositories = ReturnType<typeof createRepositories>;
10
11
  export function createRepositories(dbClient = db) {
11
12
  return {
12
13
  agents: new AgentRepository(dbClient),
14
+ heartbeats: new HeartbeatRepository(dbClient),
13
15
  messages: new MessageRepository(dbClient),
14
16
  runtimeKv: new RuntimeKvRepository(dbClient),
15
17
  sessions: new SessionRepository(dbClient),