@agentplate/cli 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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed mail store (low-level).
|
|
3
|
+
*
|
|
4
|
+
* The mail bus is how agents talk to each other and to the orchestrator. This
|
|
5
|
+
* module is the *storage* layer: a thin, synchronous CRUD wrapper over a single
|
|
6
|
+
* `messages` table. The higher-level mail client (broadcast resolution,
|
|
7
|
+
* `--inject` formatting, protocol semantics) is built on top of this store.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - The DB is opened through {@link openDatabase}, which applies WAL mode and a
|
|
11
|
+
* busy timeout — both required because many agent processes poll the same file
|
|
12
|
+
* concurrently (~ms-latency reads, occasional writes).
|
|
13
|
+
* - Columns are snake_case (SQL idiom); the public {@link MailMessage} shape is
|
|
14
|
+
* camelCase. {@link rowToMessage} is the single translation point.
|
|
15
|
+
* - `read` is stored as an INTEGER (0/1) because SQLite has no boolean type.
|
|
16
|
+
* - This is a greenfield schema, so there is no migration logic: `CREATE TABLE
|
|
17
|
+
* IF NOT EXISTS` is the whole story.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Database } from "bun:sqlite";
|
|
21
|
+
import { openDatabase } from "../db/sqlite.ts";
|
|
22
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
23
|
+
import type { MailMessage, MailPriority, MailType, NewMail } from "../types.ts";
|
|
24
|
+
|
|
25
|
+
/** Filters accepted by {@link MailStore.list}. */
|
|
26
|
+
export interface MailListFilter {
|
|
27
|
+
from?: string;
|
|
28
|
+
to?: string;
|
|
29
|
+
unread?: boolean;
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for {@link MailStore.purge}. */
|
|
34
|
+
export interface MailPurgeOptions {
|
|
35
|
+
/** Delete messages strictly older than this many days. */
|
|
36
|
+
olderThanDays?: number;
|
|
37
|
+
/** Restrict to messages this agent sent or received. */
|
|
38
|
+
agent?: string;
|
|
39
|
+
/** Delete every message (ignores the other options when true). */
|
|
40
|
+
all?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The low-level mail storage contract. */
|
|
44
|
+
export interface MailStore {
|
|
45
|
+
/** Persist a new message; assigns id/createdAt/read and applies defaults. */
|
|
46
|
+
send(mail: NewMail): MailMessage;
|
|
47
|
+
/** Messages addressed to `agent`, newest first (optionally unread-only). */
|
|
48
|
+
getInbox(agent: string, opts?: { unreadOnly?: boolean }): MailMessage[];
|
|
49
|
+
/** Mark a single message read (no-op if the id does not exist). */
|
|
50
|
+
markRead(id: string): void;
|
|
51
|
+
/** Fetch one message by id, or null if absent. */
|
|
52
|
+
getById(id: string): MailMessage | null;
|
|
53
|
+
/** Query messages with optional filters, newest first. */
|
|
54
|
+
list(filter?: MailListFilter): MailMessage[];
|
|
55
|
+
/** Reply to a message in the same thread; returns the stored reply. */
|
|
56
|
+
reply(id: string, body: string, from: string): MailMessage;
|
|
57
|
+
/** Delete messages matching `opts`; returns the number deleted. */
|
|
58
|
+
purge(opts?: MailPurgeOptions): number;
|
|
59
|
+
/** Close the underlying database connection. */
|
|
60
|
+
close(): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Row shape as stored in SQLite. Distinct from {@link MailMessage} because
|
|
65
|
+
* columns are snake_case and `read` is an integer rather than a boolean.
|
|
66
|
+
*/
|
|
67
|
+
interface MessageRow {
|
|
68
|
+
/** Monotonic insertion sequence; the deterministic ordering tiebreak. */
|
|
69
|
+
seq: number;
|
|
70
|
+
id: string;
|
|
71
|
+
from_agent: string;
|
|
72
|
+
to_agent: string;
|
|
73
|
+
subject: string;
|
|
74
|
+
body: string;
|
|
75
|
+
type: string;
|
|
76
|
+
priority: string;
|
|
77
|
+
thread_id: string | null;
|
|
78
|
+
payload: string | null;
|
|
79
|
+
read: number;
|
|
80
|
+
created_at: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const CREATE_TABLE = `
|
|
84
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
85
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
id TEXT NOT NULL UNIQUE,
|
|
87
|
+
from_agent TEXT NOT NULL,
|
|
88
|
+
to_agent TEXT NOT NULL,
|
|
89
|
+
subject TEXT NOT NULL,
|
|
90
|
+
body TEXT NOT NULL,
|
|
91
|
+
type TEXT NOT NULL,
|
|
92
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
93
|
+
thread_id TEXT,
|
|
94
|
+
payload TEXT,
|
|
95
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
created_at TEXT NOT NULL
|
|
97
|
+
)`;
|
|
98
|
+
|
|
99
|
+
// Indexes target the two hot read paths: inbox lookups (to_agent + read) and
|
|
100
|
+
// thread reconstruction (thread_id). Created up front, not per query.
|
|
101
|
+
const CREATE_INDEXES = `
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_messages_inbox ON messages(to_agent, read);
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id)`;
|
|
104
|
+
|
|
105
|
+
/** Translate a stored row (snake_case, int boolean) into a {@link MailMessage}. */
|
|
106
|
+
function rowToMessage(row: MessageRow): MailMessage {
|
|
107
|
+
return {
|
|
108
|
+
id: row.id,
|
|
109
|
+
from: row.from_agent,
|
|
110
|
+
to: row.to_agent,
|
|
111
|
+
subject: row.subject,
|
|
112
|
+
body: row.body,
|
|
113
|
+
// The CHECK-free schema trusts callers (typed at NewMail) for valid values;
|
|
114
|
+
// narrow back to the union here so consumers get the precise types.
|
|
115
|
+
type: row.type as MailType,
|
|
116
|
+
priority: row.priority as MailPriority,
|
|
117
|
+
threadId: row.thread_id,
|
|
118
|
+
payload: row.payload,
|
|
119
|
+
read: row.read === 1,
|
|
120
|
+
createdAt: row.created_at,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Insert one fully-resolved message and return the public view of it. */
|
|
125
|
+
function insertMessage(db: Database, message: MailMessage): MailMessage {
|
|
126
|
+
db.query(
|
|
127
|
+
`INSERT INTO messages
|
|
128
|
+
(id, from_agent, to_agent, subject, body, type, priority, thread_id, payload, read, created_at)
|
|
129
|
+
VALUES
|
|
130
|
+
($id, $from, $to, $subject, $body, $type, $priority, $threadId, $payload, $read, $createdAt)`,
|
|
131
|
+
).run({
|
|
132
|
+
$id: message.id,
|
|
133
|
+
$from: message.from,
|
|
134
|
+
$to: message.to,
|
|
135
|
+
$subject: message.subject,
|
|
136
|
+
$body: message.body,
|
|
137
|
+
$type: message.type,
|
|
138
|
+
$priority: message.priority,
|
|
139
|
+
$threadId: message.threadId,
|
|
140
|
+
$payload: message.payload,
|
|
141
|
+
// SQLite stores booleans as integers.
|
|
142
|
+
$read: message.read ? 1 : 0,
|
|
143
|
+
$createdAt: message.createdAt,
|
|
144
|
+
});
|
|
145
|
+
return message;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Open (or create) a mail store backed by the SQLite database at `dbPath`.
|
|
150
|
+
* Pass `":memory:"` for an ephemeral store (used in tests).
|
|
151
|
+
*/
|
|
152
|
+
export function createMailStore(dbPath: string): MailStore {
|
|
153
|
+
// Guard on `messages.seq`: the unrelated @ag-eco/agentplate-cli uses a
|
|
154
|
+
// `messages` table without our `seq` column and a stricter `type` CHECK that
|
|
155
|
+
// would reject Agentplate's delivery-pipeline message types.
|
|
156
|
+
const db = openDatabase(dbPath, { guard: { table: "messages", columns: ["seq"] } });
|
|
157
|
+
db.exec(CREATE_TABLE);
|
|
158
|
+
db.exec(CREATE_INDEXES);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
send(mail: NewMail): MailMessage {
|
|
162
|
+
// `to`/`from` are the routing keys; an empty address would silently
|
|
163
|
+
// strand the message, so reject it early with a clear error.
|
|
164
|
+
if (mail.to.trim() === "") {
|
|
165
|
+
throw new ValidationError("mail.to must be a non-empty agent name");
|
|
166
|
+
}
|
|
167
|
+
if (mail.from.trim() === "") {
|
|
168
|
+
throw new ValidationError("mail.from must be a non-empty agent name");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const message: MailMessage = {
|
|
172
|
+
id: crypto.randomUUID(),
|
|
173
|
+
from: mail.from,
|
|
174
|
+
to: mail.to,
|
|
175
|
+
subject: mail.subject,
|
|
176
|
+
body: mail.body,
|
|
177
|
+
type: mail.type,
|
|
178
|
+
priority: mail.priority ?? "normal",
|
|
179
|
+
threadId: mail.threadId ?? null,
|
|
180
|
+
payload: mail.payload ?? null,
|
|
181
|
+
read: false,
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
return insertMessage(db, message);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
getInbox(agent: string, opts?: { unreadOnly?: boolean }): MailMessage[] {
|
|
188
|
+
// Newest first so callers see the most recent messages at the top. The
|
|
189
|
+
// seq tiebreak makes ordering deterministic even when many messages
|
|
190
|
+
// share a created_at (sub-millisecond inserts); UUID ids would not.
|
|
191
|
+
const sql = opts?.unreadOnly
|
|
192
|
+
? `SELECT * FROM messages WHERE to_agent = $agent AND read = 0 ORDER BY created_at DESC, seq DESC`
|
|
193
|
+
: `SELECT * FROM messages WHERE to_agent = $agent ORDER BY created_at DESC, seq DESC`;
|
|
194
|
+
const rows = db.query(sql).all({ $agent: agent }) as MessageRow[];
|
|
195
|
+
return rows.map(rowToMessage);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
markRead(id: string): void {
|
|
199
|
+
db.query("UPDATE messages SET read = 1 WHERE id = $id").run({ $id: id });
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
getById(id: string): MailMessage | null {
|
|
203
|
+
const row = db
|
|
204
|
+
.query("SELECT * FROM messages WHERE id = $id")
|
|
205
|
+
.get({ $id: id }) as MessageRow | null;
|
|
206
|
+
return row ? rowToMessage(row) : null;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
list(filter?: MailListFilter): MailMessage[] {
|
|
210
|
+
// Build the WHERE clause dynamically from whichever filters are set.
|
|
211
|
+
// Parameters are always bound (never interpolated) to avoid injection.
|
|
212
|
+
const conditions: string[] = [];
|
|
213
|
+
const params: Record<string, string | number> = {};
|
|
214
|
+
|
|
215
|
+
if (filter?.from !== undefined) {
|
|
216
|
+
conditions.push("from_agent = $from");
|
|
217
|
+
params.$from = filter.from;
|
|
218
|
+
}
|
|
219
|
+
if (filter?.to !== undefined) {
|
|
220
|
+
conditions.push("to_agent = $to");
|
|
221
|
+
params.$to = filter.to;
|
|
222
|
+
}
|
|
223
|
+
if (filter?.unread !== undefined) {
|
|
224
|
+
conditions.push("read = $read");
|
|
225
|
+
params.$read = filter.unread ? 0 : 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
229
|
+
let sql = `SELECT * FROM messages ${where} ORDER BY created_at DESC, seq DESC`;
|
|
230
|
+
if (filter?.limit !== undefined) {
|
|
231
|
+
sql += " LIMIT $limit";
|
|
232
|
+
params.$limit = filter.limit;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rows = db.query(sql).all(params) as MessageRow[];
|
|
236
|
+
return rows.map(rowToMessage);
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
reply(id: string, body: string, from: string): MailMessage {
|
|
240
|
+
const original = this.getById(id);
|
|
241
|
+
if (!original) {
|
|
242
|
+
throw new NotFoundError(`Cannot reply: message "${id}" not found`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A reply joins the original's thread. If the original started a new
|
|
246
|
+
// thread (threadId === null) we adopt its id as the thread root so the
|
|
247
|
+
// whole exchange shares one thread id.
|
|
248
|
+
const reply: MailMessage = {
|
|
249
|
+
id: crypto.randomUUID(),
|
|
250
|
+
from,
|
|
251
|
+
to: original.from,
|
|
252
|
+
subject: original.subject,
|
|
253
|
+
body,
|
|
254
|
+
type: "result",
|
|
255
|
+
priority: original.priority,
|
|
256
|
+
threadId: original.threadId ?? original.id,
|
|
257
|
+
payload: null,
|
|
258
|
+
read: false,
|
|
259
|
+
createdAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
return insertMessage(db, reply);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
purge(opts?: MailPurgeOptions): number {
|
|
265
|
+
// `all` is the nuclear option and ignores the finer-grained filters.
|
|
266
|
+
if (opts?.all) {
|
|
267
|
+
const before = countRows(db, "", {});
|
|
268
|
+
db.query("DELETE FROM messages").run();
|
|
269
|
+
return before;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const conditions: string[] = [];
|
|
273
|
+
const params: Record<string, string> = {};
|
|
274
|
+
|
|
275
|
+
if (opts?.olderThanDays !== undefined) {
|
|
276
|
+
// Cutoff is an ISO timestamp so the comparison matches created_at's
|
|
277
|
+
// lexicographic ordering (ISO-8601 sorts chronologically as text).
|
|
278
|
+
const cutoffMs = Date.now() - opts.olderThanDays * 24 * 60 * 60 * 1000;
|
|
279
|
+
conditions.push("created_at < $cutoff");
|
|
280
|
+
params.$cutoff = new Date(cutoffMs).toISOString();
|
|
281
|
+
}
|
|
282
|
+
if (opts?.agent !== undefined) {
|
|
283
|
+
conditions.push("(from_agent = $agent OR to_agent = $agent)");
|
|
284
|
+
params.$agent = opts.agent;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// With no criteria there is nothing to purge — refuse to wipe the table
|
|
288
|
+
// implicitly (that path is reserved for the explicit `all` flag).
|
|
289
|
+
if (conditions.length === 0) {
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
294
|
+
const before = countRows(db, where, params);
|
|
295
|
+
db.query(`DELETE FROM messages ${where}`).run(params);
|
|
296
|
+
return before;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
close(): void {
|
|
300
|
+
db.close();
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Count rows matching an optional WHERE clause (used to report purge totals). */
|
|
306
|
+
function countRows(db: Database, where: string, params: Record<string, string | number>): number {
|
|
307
|
+
const row = db.query(`SELECT COUNT(*) AS n FROM messages ${where}`).get(params) as {
|
|
308
|
+
n: number;
|
|
309
|
+
} | null;
|
|
310
|
+
return row?.n ?? 0;
|
|
311
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the sentinel-file merge lock.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories so the O_EXCL filesystem semantics are genuinely
|
|
5
|
+
* exercised (no mocking of fs). Each test works inside a fresh temp root that
|
|
6
|
+
* stands in for a project root containing `.agentplate/`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { WorktreeError } from "../errors.ts";
|
|
15
|
+
import { acquireMergeLock, releaseMergeLock, withMergeLock } from "./lock.ts";
|
|
16
|
+
|
|
17
|
+
let root: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-lock-"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
// Best-effort: drop the lock before removing the tree.
|
|
25
|
+
releaseMergeLock(root);
|
|
26
|
+
rmSync(root, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("acquireMergeLock / releaseMergeLock", () => {
|
|
30
|
+
test("first acquire succeeds and creates the sentinel file", () => {
|
|
31
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
32
|
+
expect(existsSync(join(root, ".agentplate", "merge.lock"))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("second acquire fails while the lock is held, then succeeds after release", () => {
|
|
36
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
37
|
+
// Lock is held: a second attempt must report contention (false), not throw.
|
|
38
|
+
expect(acquireMergeLock(root)).toBe(false);
|
|
39
|
+
|
|
40
|
+
releaseMergeLock(root);
|
|
41
|
+
// After release the lock is free again.
|
|
42
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("releaseMergeLock is idempotent (no throw when already released)", () => {
|
|
46
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
47
|
+
releaseMergeLock(root);
|
|
48
|
+
expect(() => releaseMergeLock(root)).not.toThrow();
|
|
49
|
+
expect(existsSync(join(root, ".agentplate", "merge.lock"))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("acquire creates .agentplate/ if it does not exist", () => {
|
|
53
|
+
// Fresh temp root has no .agentplate/ subdir yet.
|
|
54
|
+
expect(existsSync(join(root, ".agentplate"))).toBe(false);
|
|
55
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
56
|
+
expect(existsSync(join(root, ".agentplate"))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("withMergeLock", () => {
|
|
61
|
+
test("runs fn while holding the lock and releases afterward", async () => {
|
|
62
|
+
let observedDuringRun = false;
|
|
63
|
+
const result = await withMergeLock(root, async () => {
|
|
64
|
+
// Inside the critical section the lock is held, so a fresh acquire fails.
|
|
65
|
+
observedDuringRun = acquireMergeLock(root) === false;
|
|
66
|
+
return 42;
|
|
67
|
+
});
|
|
68
|
+
expect(result).toBe(42);
|
|
69
|
+
expect(observedDuringRun).toBe(true);
|
|
70
|
+
// Lock released after the body resolved: it can be taken again.
|
|
71
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("releases the lock even when fn throws", async () => {
|
|
75
|
+
await expect(
|
|
76
|
+
withMergeLock(root, async () => {
|
|
77
|
+
throw new Error("boom");
|
|
78
|
+
}),
|
|
79
|
+
).rejects.toThrow("boom");
|
|
80
|
+
// Despite the throw, the lock must be free.
|
|
81
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws WorktreeError if the lock is already held", async () => {
|
|
85
|
+
expect(acquireMergeLock(root)).toBe(true);
|
|
86
|
+
await expect(withMergeLock(root, async () => "unused")).rejects.toBeInstanceOf(WorktreeError);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Sentinel-file merge lock.
|
|
2
|
+
//
|
|
3
|
+
// WHY a file lock (and not an in-process mutex or a SQLite row): `agentplate merge`
|
|
4
|
+
// can be invoked by several independent processes at once (the operator's shell,
|
|
5
|
+
// the coordinator agent, a watchdog retry). They are separate OS processes, so an
|
|
6
|
+
// in-memory lock is useless. A sentinel file created with O_EXCL is atomic at the
|
|
7
|
+
// filesystem level — exactly one caller can win the create, everyone else sees
|
|
8
|
+
// EEXIST — which gives us a cheap cross-process mutex without a daemon.
|
|
9
|
+
//
|
|
10
|
+
// We deliberately keep this dumb: no PID tracking, no stale-lock reaping. The lock
|
|
11
|
+
// is held only for the duration of a single `withMergeLock` body (one merge), and
|
|
12
|
+
// `releaseMergeLock` runs in a `finally`, so a crash mid-merge is the only way to
|
|
13
|
+
// leak it. Recovering from that is a manual `agentplate clean`-style concern, not
|
|
14
|
+
// something we want to guess at here (auto-reaping a "stale" lock races with a
|
|
15
|
+
// merge that is simply slow).
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { WorktreeError } from "../errors.ts";
|
|
21
|
+
|
|
22
|
+
/** Absolute path to the sentinel file for a given project root. */
|
|
23
|
+
function lockPath(root: string): string {
|
|
24
|
+
return join(root, ".agentplate", "merge.lock");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Try to acquire the merge lock for `root`.
|
|
29
|
+
*
|
|
30
|
+
* Returns `true` if this call created the sentinel (caller now holds the lock),
|
|
31
|
+
* `false` if it was already held by someone else. Never throws on contention —
|
|
32
|
+
* contention is an expected, recoverable condition the caller decides how to
|
|
33
|
+
* handle.
|
|
34
|
+
*/
|
|
35
|
+
export function acquireMergeLock(root: string): boolean {
|
|
36
|
+
const path = lockPath(root);
|
|
37
|
+
// Ensure `.agentplate/` exists; a fresh checkout or a targeted test dir may not
|
|
38
|
+
// have it yet, and `wx` would otherwise fail with ENOENT instead of EEXIST.
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
try {
|
|
41
|
+
// `flag: "wx"` === open(O_CREAT | O_EXCL | O_WRONLY): atomic create-or-fail.
|
|
42
|
+
// The payload is informational only (a timestamp aids manual debugging).
|
|
43
|
+
writeFileSync(path, `${new Date().toISOString()}\n`, { flag: "wx" });
|
|
44
|
+
return true;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// EEXIST means another process holds the lock — the one outcome we treat as
|
|
47
|
+
// "not acquired" rather than an error. Anything else (EACCES, EROFS, …) is a
|
|
48
|
+
// real filesystem failure and must surface.
|
|
49
|
+
if (err instanceof Error && "code" in err && err.code === "EEXIST") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Release the merge lock for `root`. Idempotent: removing an already-absent lock
|
|
58
|
+
* is a no-op, so double-release (e.g. a `finally` after an early manual release)
|
|
59
|
+
* is safe.
|
|
60
|
+
*/
|
|
61
|
+
export function releaseMergeLock(root: string): void {
|
|
62
|
+
const path = lockPath(root);
|
|
63
|
+
if (existsSync(path)) {
|
|
64
|
+
rmSync(path, { force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run `fn` while holding the merge lock, releasing it afterwards no matter how
|
|
70
|
+
* `fn` settles. Throws `WorktreeError` if the lock is already held — callers that
|
|
71
|
+
* want to wait/retry should loop on `acquireMergeLock` themselves.
|
|
72
|
+
*/
|
|
73
|
+
export async function withMergeLock<T>(root: string, fn: () => Promise<T>): Promise<T> {
|
|
74
|
+
if (!acquireMergeLock(root)) {
|
|
75
|
+
throw new WorktreeError(
|
|
76
|
+
`Merge lock is already held (${lockPath(root)}). Another merge is in progress.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await fn();
|
|
81
|
+
} finally {
|
|
82
|
+
releaseMergeLock(root);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the FIFO merge queue.
|
|
3
|
+
*
|
|
4
|
+
* Uses a real on-disk temp-file SQLite database (not `:memory:`) so we exercise
|
|
5
|
+
* the same WAL-mode path production uses; an in-memory DB would skip WAL/journal
|
|
6
|
+
* behavior entirely. Each test gets a fresh temp directory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { MergeEntry } from "../types.ts";
|
|
15
|
+
import { createMergeQueue, type MergeQueue } from "./queue.ts";
|
|
16
|
+
|
|
17
|
+
let dir: string;
|
|
18
|
+
let queue: MergeQueue;
|
|
19
|
+
|
|
20
|
+
/** Convenience: enqueue with sensible defaults, overridable per field. */
|
|
21
|
+
function add(overrides: Partial<Omit<MergeEntry, "id" | "createdAt" | "status">> = {}): MergeEntry {
|
|
22
|
+
return queue.enqueue({
|
|
23
|
+
branchName: overrides.branchName ?? "agent/feature",
|
|
24
|
+
agentName: overrides.agentName ?? "builder-1",
|
|
25
|
+
taskId: overrides.taskId ?? "task-1",
|
|
26
|
+
targetBranch: overrides.targetBranch ?? "main",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "agentplate-queue-"));
|
|
32
|
+
queue = createMergeQueue(join(dir, "merge-queue.db"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
queue.close();
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("createMergeQueue", () => {
|
|
41
|
+
test("enqueue assigns id, createdAt, and pending status", () => {
|
|
42
|
+
const entry = add({ branchName: "agent/a", agentName: "a", taskId: "t-a" });
|
|
43
|
+
expect(entry.id).toBeString();
|
|
44
|
+
expect(entry.id.length).toBeGreaterThan(0);
|
|
45
|
+
expect(entry.status).toBe("pending");
|
|
46
|
+
expect(entry.branchName).toBe("agent/a");
|
|
47
|
+
expect(entry.agentName).toBe("a");
|
|
48
|
+
expect(entry.taskId).toBe("t-a");
|
|
49
|
+
expect(entry.targetBranch).toBe("main");
|
|
50
|
+
// createdAt is an ISO-8601 string round-trippable through Date.
|
|
51
|
+
expect(new Date(entry.createdAt).toISOString()).toBe(entry.createdAt);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("each enqueue produces a unique id", () => {
|
|
55
|
+
const a = add();
|
|
56
|
+
const b = add();
|
|
57
|
+
expect(a.id).not.toBe(b.id);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("listPending returns only pending entries, oldest first", () => {
|
|
61
|
+
const a = add({ branchName: "agent/a" });
|
|
62
|
+
const b = add({ branchName: "agent/b" });
|
|
63
|
+
const c = add({ branchName: "agent/c" });
|
|
64
|
+
|
|
65
|
+
// Mark the middle one merged — it should drop out of listPending.
|
|
66
|
+
queue.markStatus(b.id, "merged");
|
|
67
|
+
|
|
68
|
+
const pending = queue.listPending();
|
|
69
|
+
expect(pending.map((e) => e.branchName)).toEqual(["agent/a", "agent/c"]);
|
|
70
|
+
expect(pending.map((e) => e.id)).toEqual([a.id, c.id]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("dequeue removes and returns the oldest pending entry (FIFO)", () => {
|
|
74
|
+
const first = add({ branchName: "agent/first" });
|
|
75
|
+
const second = add({ branchName: "agent/second" });
|
|
76
|
+
|
|
77
|
+
const dq1 = queue.dequeue();
|
|
78
|
+
expect(dq1?.id).toBe(first.id);
|
|
79
|
+
expect(dq1?.branchName).toBe("agent/first");
|
|
80
|
+
|
|
81
|
+
// It is gone now; the next dequeue yields the second entry.
|
|
82
|
+
const dq2 = queue.dequeue();
|
|
83
|
+
expect(dq2?.id).toBe(second.id);
|
|
84
|
+
|
|
85
|
+
expect(queue.dequeue()).toBeNull();
|
|
86
|
+
expect(queue.listPending()).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("dequeue skips non-pending entries", () => {
|
|
90
|
+
const a = add({ branchName: "agent/a" });
|
|
91
|
+
const b = add({ branchName: "agent/b" });
|
|
92
|
+
|
|
93
|
+
// First entry already failed — dequeue must return the next pending one.
|
|
94
|
+
queue.markStatus(a.id, "failed");
|
|
95
|
+
|
|
96
|
+
const dq = queue.dequeue();
|
|
97
|
+
expect(dq?.id).toBe(b.id);
|
|
98
|
+
// The failed entry remains in the table (it was not dequeued).
|
|
99
|
+
expect(queue.listPending()).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("dequeue returns null on an empty queue", () => {
|
|
103
|
+
expect(queue.dequeue()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("markStatus updates an entry and removes it from listPending", () => {
|
|
107
|
+
const a = add();
|
|
108
|
+
expect(queue.listPending()).toHaveLength(1);
|
|
109
|
+
|
|
110
|
+
queue.markStatus(a.id, "merged");
|
|
111
|
+
expect(queue.listPending()).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("markStatus throws NotFoundError for an unknown id", () => {
|
|
115
|
+
expect(() => queue.markStatus("does-not-exist", "merged")).toThrow(
|
|
116
|
+
/No merge queue entry with id/,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("queue state persists across reopen (same db file)", () => {
|
|
121
|
+
const a = add({ branchName: "agent/persist" });
|
|
122
|
+
queue.close();
|
|
123
|
+
|
|
124
|
+
const reopened = createMergeQueue(join(dir, "merge-queue.db"));
|
|
125
|
+
try {
|
|
126
|
+
const pending = reopened.listPending();
|
|
127
|
+
expect(pending).toHaveLength(1);
|
|
128
|
+
expect(pending[0]?.id).toBe(a.id);
|
|
129
|
+
expect(pending[0]?.branchName).toBe("agent/persist");
|
|
130
|
+
} finally {
|
|
131
|
+
reopened.close();
|
|
132
|
+
// Re-open the module-level handle so afterEach's close() is valid.
|
|
133
|
+
queue = createMergeQueue(join(dir, "merge-queue.db"));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|