Skip to content

Commit a6226d9

Browse files
fix: address PR #65 review comments
- Fix MessagesPage timestamp types (number not string) and cap messages at 500 - Fix broker resubscribeAll to check boolean returns and revert to file polling - Fix redis-bus subscribe to only add channels after successful Redis subscribe - Fix file-store polling to initialize timestamp at 0 for replay - Fix system restore to default backupIndex to 1 - Fix state-manager listBackups to sort by recency descending - Fix state-manager restoreFromBackup to use persistState queue - Fix chocolatier notification config to load from repo .cocopilot/config.json - Remove unused generateFixupSummary import from temperer - Fix chocolatier.test.ts to use Partial<CocoConfig> and Partial<RepoState> - Fix workers routes to use const for non-reassigned variables - Implement per-repo template loading in templates/workers routes Co-authored-by: patrick-knight <7575792+patrick-knight@users.noreply.github.com>
1 parent c932130 commit a6226d9

File tree

11 files changed

+185
-63
lines changed

11 files changed

+185
-63
lines changed

src/agents/chocolatier.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ContainerStatus, ContainerType } from "../docker/index.js";
1010
import type { MessageBroker } from "../messaging/index.js";
1111
import { MessageType } from "../messaging/index.js";
1212
import type { CocoMessage } from "../messaging/index.js";
13+
import type { CocoConfig, RepoState } from "../types/index.js";
1314

1415
// ---------------------------------------------------------------------------
1516
// Mocks
@@ -1032,7 +1033,7 @@ describe("Chocolatier", () => {
10321033

10331034
stateManager.getConfig.mockReturnValue({
10341035
workerTimeout: "4h",
1035-
} as any);
1036+
} as Partial<CocoConfig>);
10361037

10371038
stateManager.getRepo.mockReturnValue({
10381039
id: "repo-1",
@@ -1050,12 +1051,12 @@ describe("Chocolatier", () => {
10501051
},
10511052
},
10521053
agents: {},
1053-
} as any);
1054+
} as Partial<RepoState>);
10541055

10551056
containerManager.list.mockResolvedValue([]);
10561057
containerManager.stop.mockResolvedValue(undefined);
10571058

1058-
const report = await chocolatier.runHealthCheck();
1059+
await chocolatier.runHealthCheck();
10591060

10601061
expect(stateManager.updateWorkerStatus).toHaveBeenCalledWith(
10611062
"test-repo",
@@ -1081,7 +1082,7 @@ describe("Chocolatier", () => {
10811082

10821083
stateManager.getConfig.mockReturnValue({
10831084
workerTimeout: "4h",
1084-
} as any);
1085+
} as Partial<CocoConfig>);
10851086

10861087
stateManager.getRepo.mockReturnValue({
10871088
id: "repo-1",
@@ -1099,7 +1100,7 @@ describe("Chocolatier", () => {
10991100
},
11001101
},
11011102
agents: {},
1102-
} as any);
1103+
} as Partial<RepoState>);
11031104

11041105
containerManager.list.mockResolvedValue([
11051106
{
@@ -1129,7 +1130,7 @@ describe("Chocolatier", () => {
11291130

11301131
stateManager.getConfig.mockReturnValue({
11311132
workerTimeout: "4h",
1132-
} as any);
1133+
} as Partial<CocoConfig>);
11331134

11341135
stateManager.getRepo.mockReturnValue({
11351136
id: "repo-1",
@@ -1147,7 +1148,7 @@ describe("Chocolatier", () => {
11471148
},
11481149
},
11491150
agents: {},
1150-
} as any);
1151+
} as Partial<RepoState>);
11511152

11521153
containerManager.list.mockResolvedValue([]);
11531154
containerManager.stop.mockResolvedValue(undefined);

src/agents/chocolatier.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313

1414
import { EventEmitter } from "node:events";
1515
import { randomUUID } from "node:crypto";
16+
import * as fs from "node:fs";
17+
import * as path from "node:path";
1618

1719
import { StateManager } from "../state/index.js";
1820
import type { WorkerState } from "../state/index.js";
21+
import type { RepoConfig } from "../state/schemas.js";
1922
import {
2023
ContainerManager,
2124
ContainerType,
@@ -543,6 +546,21 @@ export class Chocolatier extends EventEmitter {
543546
});
544547
}
545548

549+
/**
550+
* Load per-repo configuration from .cocopilot/config.json
551+
*/
552+
private loadRepoConfig(localPath: string): RepoConfig {
553+
try {
554+
const configPath = path.join(localPath, ".cocopilot", "config.json");
555+
if (fs.existsSync(configPath)) {
556+
return JSON.parse(fs.readFileSync(configPath, "utf-8")) as RepoConfig;
557+
}
558+
} catch {
559+
// Ignore read errors
560+
}
561+
return {};
562+
}
563+
546564
/**
547565
* Fire-and-forget: create a GitHub notification issue if configured.
548566
* Failures are logged but never block agent operations.
@@ -551,9 +569,8 @@ export class Chocolatier extends EventEmitter {
551569
const repo = this.stateManager.getRepo(this.config.repoName);
552570
if (!repo) return;
553571

554-
const notifConfig: NotificationConfig =
555-
(repo as unknown as { config?: { notifications?: NotificationConfig } }).config?.notifications
556-
?? DEFAULT_NOTIFICATION_CONFIG;
572+
const repoConfig = this.loadRepoConfig(repo.localPath);
573+
const notifConfig = repoConfig.notifications ?? DEFAULT_NOTIFICATION_CONFIG;
557574

558575
if (!shouldNotify(notifConfig, event.type)) return;
559576

src/agents/temperer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
} from "../messaging/index.js";
2121
import {
2222
getCIStatus,
23-
generateFixupSummary,
2423
type CIExecFn,
2524
checkMergeability,
2625
shouldNotify,

src/api/v1/system.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ export async function restoreBackup(
6464
return;
6565
}
6666

67+
const index = backupIndex ?? 1;
68+
6769
try {
68-
const ok = await deps.stateManager.restoreFromBackup(backupIndex);
70+
const ok = await deps.stateManager.restoreFromBackup(index);
6971
if (ok) {
7072
res.json({
7173
success: true,
72-
message: `State restored from backup ${backupIndex ?? 1}`,
74+
message: `State restored from backup ${index}`,
7375
repositories: Object.keys(deps.stateManager.getRepos()),
7476
});
7577
} else {

src/api/v1/templates.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
* GET /api/v1/templates/:id — Get a specific template by ID
66
*/
77

8+
import * as fs from "node:fs";
9+
import * as path from "node:path";
810
import { Router } from "express";
911
import type { StateManager } from "../../state/index.js";
10-
import type { TaskTemplate } from "../../state/schemas.js";
12+
import type { TaskTemplate, RepoConfig } from "../../state/schemas.js";
1113
import { createApiError } from "../../server/middleware/error-handler.js";
1214

1315
// ---------------------------------------------------------------------------
@@ -90,6 +92,21 @@ export function resolveTemplate(
9092
return BUILTIN_TEMPLATES.find((t) => t.id === templateId);
9193
}
9294

95+
/**
96+
* Load per-repo configuration from .cocopilot/config.json
97+
*/
98+
function loadRepoConfig(localPath: string): RepoConfig {
99+
try {
100+
const configPath = path.join(localPath, ".cocopilot", "config.json");
101+
if (fs.existsSync(configPath)) {
102+
return JSON.parse(fs.readFileSync(configPath, "utf-8")) as RepoConfig;
103+
}
104+
} catch {
105+
// Ignore read errors
106+
}
107+
return {};
108+
}
109+
93110
// ---------------------------------------------------------------------------
94111
// Routes
95112
// ---------------------------------------------------------------------------
@@ -110,10 +127,8 @@ export function templatesRoutes(deps: TemplatesDeps): Router {
110127
if (repoName) {
111128
const repo = stateManager.getRepo(repoName);
112129
if (repo) {
113-
// Repo-specific templates would be stored in repo config
114-
// For now we look at a hypothetical templates field loaded from config
115-
// This is a placeholder for when per-repo config loading is wired up
116-
repoTemplates = [];
130+
const repoConfig = loadRepoConfig(repo.localPath);
131+
repoTemplates = repoConfig.templates ?? [];
117132
}
118133
}
119134

src/api/v1/workers.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,32 @@
1212
* POST /api/v1/workers/:name/resume -- Resume a paused worker
1313
*/
1414

15+
import * as fs from "node:fs";
16+
import * as path from "node:path";
1517
import { Router } from "express";
1618
import type { StateManager } from "../../state/index.js";
1719
import type { MessageBroker } from "../../messaging/index.js";
1820
import { MessageType } from "../../messaging/index.js";
1921
import { chocolatierAgentName } from "../../agents/chocolatier.js";
2022
import { createApiError } from "../../server/middleware/error-handler.js";
23+
import type { RepoConfig, TaskTemplate } from "../../state/schemas.js";
2124
import { resolveTemplate, BUILTIN_TEMPLATES } from "./templates.js";
2225

26+
/**
27+
* Load per-repo configuration from .cocopilot/config.json
28+
*/
29+
function loadRepoConfig(localPath: string): RepoConfig {
30+
try {
31+
const configPath = path.join(localPath, ".cocopilot", "config.json");
32+
if (fs.existsSync(configPath)) {
33+
return JSON.parse(fs.readFileSync(configPath, "utf-8")) as RepoConfig;
34+
}
35+
} catch {
36+
// Ignore read errors
37+
}
38+
return {};
39+
}
40+
2341
export function extWorkerRoutes(
2442
stateManager: StateManager,
2543
broker: MessageBroker,
@@ -29,11 +47,32 @@ export function extWorkerRoutes(
2947
// POST / -- Spawn a worker in a specific repository
3048
// Sends SPAWN_WORKER message to Chocolatier which actually spawns the container
3149
router.post("/", async (req, res, next) => {
32-
let { task, repoName, branch, name, model, pushTo, templateId } = req.body ?? {};
50+
const body = req.body ?? {};
51+
let { task } = body;
52+
const { repoName, branch, name, model, pushTo, templateId } = body;
53+
54+
if (!repoName) {
55+
next(createApiError(400, "Missing required field: repoName"));
56+
return;
57+
}
58+
59+
// Verify repository exists
60+
const repo = stateManager.getRepo(repoName);
61+
if (!repo) {
62+
next(createApiError(404, `Repository "${repoName}" not tracked`));
63+
return;
64+
}
65+
66+
// Get repo-specific templates if resolving from templateId
67+
let repoTemplates: TaskTemplate[] | undefined;
68+
if (templateId) {
69+
const repoConfig = loadRepoConfig(repo.localPath);
70+
repoTemplates = repoConfig.templates;
71+
}
3372

3473
// Resolve task from template if templateId is provided
3574
if (templateId && !task) {
36-
const template = resolveTemplate(templateId);
75+
const template = resolveTemplate(templateId, repoTemplates);
3776
if (!template) {
3877
next(createApiError(404, `Template "${templateId}" not found`));
3978
return;
@@ -45,17 +84,6 @@ export function extWorkerRoutes(
4584
next(createApiError(400, "Missing required field: task"));
4685
return;
4786
}
48-
if (!repoName) {
49-
next(createApiError(400, "Missing required field: repoName"));
50-
return;
51-
}
52-
53-
// Verify repository exists
54-
const repo = stateManager.getRepo(repoName);
55-
if (!repo) {
56-
next(createApiError(404, `Repository "${repoName}" not tracked`));
57-
return;
58-
}
5987

6088
try {
6189
// Send SPAWN_WORKER message to Chocolatier

src/messaging/broker.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,23 @@ export class MessageBroker {
224224
`Use scopedAgentName() or scopedWorkerName() to avoid cross-repo collisions.`,
225225
);
226226
}
227-
this.agentHandlers.set(agentName, handler);
228-
this.lastPollTimestamps.set(agentName, Date.now());
227+
228+
// Wrap handler to update timestamp watermark on every delivery
229+
const wrappedHandler: MessageHandler = async (msg) => {
230+
await handler(msg);
231+
// Update watermark to prevent re-delivery during Redis→file fallback
232+
const currentTs = this.lastPollTimestamps.get(agentName) ?? 0;
233+
if (msg.timestamp > currentTs) {
234+
this.lastPollTimestamps.set(agentName, msg.timestamp);
235+
}
236+
};
237+
238+
this.agentHandlers.set(agentName, wrappedHandler);
239+
// Initialize to 0 to replay pending messages when Redis is unavailable
240+
this.lastPollTimestamps.set(agentName, 0);
229241

230242
if (this.redisAvailable) {
231-
const ok = await this.bus.subscribe(agentName, handler);
243+
const ok = await this.bus.subscribe(agentName, wrappedHandler);
232244
if (!ok) {
233245
console.warn("[MessageBroker] Redis subscribe failed, falling back to file store polling");
234246
this.redisAvailable = false;
@@ -339,10 +351,10 @@ export class MessageBroker {
339351
/** Re-subscribe all registered agents to Redis after reconnection. */
340352
private async resubscribeAll(): Promise<void> {
341353
for (const [agentName, handler] of this.agentHandlers) {
342-
try {
343-
await this.bus.subscribe(agentName, handler);
344-
} catch {
354+
const ok = await this.bus.subscribe(agentName, handler);
355+
if (!ok) {
345356
// If resubscribe fails, remain in file-store-only mode
357+
console.warn("[MessageBroker] Resubscribe failed, reverting to file-store polling");
346358
this.redisAvailable = false;
347359
this.startFilePolling();
348360
return;

src/messaging/redis-bus.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,28 @@ export class RedisMessageBus {
151151
const toSubscribe: string[] = [];
152152
if (!this.subscribedChannels.has(channel)) {
153153
toSubscribe.push(channel);
154-
this.subscribedChannels.add(channel);
155154
}
156155
if (!this.subscribedChannels.has(BROADCAST_CHANNEL)) {
157156
toSubscribe.push(BROADCAST_CHANNEL);
158-
this.subscribedChannels.add(BROADCAST_CHANNEL);
159157
}
160158

161159
if (toSubscribe.length > 0) {
162160
try {
163161
await this.sub.subscribe(...toSubscribe);
162+
// Only add to subscribedChannels after successful Redis subscribe
163+
for (const ch of toSubscribe) {
164+
this.subscribedChannels.add(ch);
165+
}
164166
} catch {
167+
// Roll back handlers on failure
168+
this.handlers.get(channel)?.delete(handler);
169+
if (this.handlers.get(channel)?.size === 0) {
170+
this.handlers.delete(channel);
171+
}
172+
this.handlers.get(BROADCAST_CHANNEL)?.delete(handler);
173+
if (this.handlers.get(BROADCAST_CHANNEL)?.size === 0) {
174+
this.handlers.delete(BROADCAST_CHANNEL);
175+
}
165176
return false;
166177
}
167178
}

0 commit comments

Comments
 (0)