bopodev-db 0.1.34 → 0.1.35

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/src/migrate.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { applyDatabaseMigrations, createDb } from "./client";
2
+ import { resolveDefaultDbPath } from "./default-paths";
3
+ import { loadMigrateEnv } from "./load-migrate-env";
2
4
 
3
5
  async function main() {
6
+ loadMigrateEnv();
7
+ logMigrationTarget();
4
8
  const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
5
9
  const connection = await createDb(dbPath);
6
10
  try {
@@ -18,3 +22,16 @@ function normalizeOptionalDbPath(value: string | undefined) {
18
22
  const normalized = value?.trim();
19
23
  return normalized && normalized.length > 0 ? normalized : undefined;
20
24
  }
25
+
26
+ function logMigrationTarget() {
27
+ const external = process.env.DATABASE_URL?.trim();
28
+ if (external) {
29
+ // eslint-disable-next-line no-console
30
+ console.log("[bopodev-db] Migrating database from DATABASE_URL (same sources as API: .env.local, .env at repo root).");
31
+ return;
32
+ }
33
+ const configured = process.env.BOPO_DB_PATH?.trim();
34
+ const dataPath = configured && configured.length > 0 ? configured : resolveDefaultDbPath();
35
+ // eslint-disable-next-line no-console
36
+ console.log(`[bopodev-db] Migrating embedded Postgres at ${dataPath} (set DATABASE_URL to use an external Postgres).`);
37
+ }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "agents" ADD COLUMN "can_assign_agents" boolean DEFAULT true NOT NULL;
2
+ ALTER TABLE "agents" ADD COLUMN "can_create_issues" boolean DEFAULT true NOT NULL;
@@ -0,0 +1 @@
1
+ ALTER TABLE "agents" ADD COLUMN "lucide_icon_name" text DEFAULT '' NOT NULL;
@@ -0,0 +1,17 @@
1
+ CREATE TABLE "plugin_installs" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
4
+ "plugin_id" text NOT NULL REFERENCES "plugins"("id") ON DELETE CASCADE,
5
+ "plugin_version" text NOT NULL,
6
+ "source_type" text DEFAULT 'registry' NOT NULL,
7
+ "source_ref" text,
8
+ "integrity" text,
9
+ "build_hash" text,
10
+ "artifact_path" text,
11
+ "manifest_json" text DEFAULT '{}' NOT NULL,
12
+ "status" text DEFAULT 'active' NOT NULL,
13
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE INDEX "idx_plugin_installs_company_plugin_created"
17
+ ON "plugin_installs" ("company_id", "plugin_id", "created_at");
@@ -64,6 +64,27 @@
64
64
  "when": 1743300000000,
65
65
  "tag": "0008_goals_parent_goal_fk",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1743400000000,
72
+ "tag": "0009_agent_issue_permissions",
73
+ "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1743500000000,
79
+ "tag": "0010_agent_lucide_icon",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1743600000000,
86
+ "tag": "0011_plugin_installs",
87
+ "breakpoints": true
67
88
  }
68
89
  ]
69
90
  }
@@ -1,4 +1,4 @@
1
- import { and, asc, count, desc, eq, gte, lt } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, gte, lt, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { BopoDb } from "../client";
4
4
  import { companyAssistantMessages, companyAssistantThreads } from "../schema";
@@ -45,6 +45,56 @@ export async function getAssistantThreadById(db: BopoDb, companyId: string, thre
45
45
  return row ?? null;
46
46
  }
47
47
 
48
+ /** Removes the thread and its messages (cascade). Returns whether a row was deleted. */
49
+ export async function deleteAssistantThread(db: BopoDb, companyId: string, threadId: string): Promise<boolean> {
50
+ const [row] = await db
51
+ .delete(companyAssistantThreads)
52
+ .where(and(eq(companyAssistantThreads.id, threadId), eq(companyAssistantThreads.companyId, companyId)))
53
+ .returning({ id: companyAssistantThreads.id });
54
+ return Boolean(row);
55
+ }
56
+
57
+ export type AssistantThreadSummary = {
58
+ id: string;
59
+ createdAt: Date;
60
+ updatedAt: Date;
61
+ previewBody: string | null;
62
+ };
63
+
64
+ /** Threads for the company, newest activity first, with last message body as preview (if any). */
65
+ export async function listAssistantThreadsForCompany(
66
+ db: BopoDb,
67
+ companyId: string,
68
+ limit = 50
69
+ ): Promise<AssistantThreadSummary[]> {
70
+ const capped = Math.min(Math.max(1, limit), 100);
71
+ const result = await db.execute(sql`
72
+ SELECT t.id, t.created_at, t.updated_at, lm.body AS preview_body
73
+ FROM company_assistant_threads t
74
+ LEFT JOIN LATERAL (
75
+ SELECT body FROM company_assistant_messages
76
+ WHERE thread_id = t.id
77
+ ORDER BY created_at DESC
78
+ LIMIT 1
79
+ ) lm ON true
80
+ WHERE t.company_id = ${companyId}
81
+ ORDER BY t.updated_at DESC
82
+ LIMIT ${capped}
83
+ `);
84
+ const rows = result as unknown as Array<{
85
+ id: string;
86
+ created_at: Date | string;
87
+ updated_at: Date | string;
88
+ preview_body: string | null;
89
+ }>;
90
+ return rows.map((r) => ({
91
+ id: r.id,
92
+ createdAt: r.created_at instanceof Date ? r.created_at : new Date(String(r.created_at)),
93
+ updatedAt: r.updated_at instanceof Date ? r.updated_at : new Date(String(r.updated_at)),
94
+ previewBody: r.preview_body
95
+ }));
96
+ }
97
+
48
98
  export async function touchAssistantThread(db: BopoDb, threadId: string) {
49
99
  await db
50
100
  .update(companyAssistantThreads)
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, gt, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, gt, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { BopoDb } from "../client";
4
4
  import {
@@ -18,6 +18,7 @@ import {
18
18
  issueGoals,
19
19
  issues,
20
20
  pluginConfigs,
21
+ pluginInstalls,
21
22
  pluginRuns,
22
23
  plugins,
23
24
  projectWorkspaces,
@@ -521,8 +522,8 @@ export async function createIssue(
521
522
  assigneeAgentId?: string | null;
522
523
  labels?: string[];
523
524
  tags?: string[];
524
- loopId?: string | null;
525
- loopRunId?: string | null;
525
+ routineId?: string | null;
526
+ routineRunId?: string | null;
526
527
  }
527
528
  ) {
528
529
  await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
@@ -549,8 +550,8 @@ export async function createIssue(
549
550
  assigneeAgentId: input.assigneeAgentId ?? null,
550
551
  labelsJson: JSON.stringify(input.labels ?? []),
551
552
  tagsJson: JSON.stringify(input.tags ?? []),
552
- loopId: input.loopId ?? null,
553
- loopRunId: input.loopRunId ?? null
553
+ routineId: input.routineId ?? null,
554
+ routineRunId: input.routineRunId ?? null
554
555
  })
555
556
  .returning();
556
557
  if (!row) {
@@ -1079,6 +1080,8 @@ export async function createAgent(
1079
1080
  heartbeatCron: string;
1080
1081
  monthlyBudgetUsd: string;
1081
1082
  canHireAgents?: boolean;
1083
+ canAssignAgents?: boolean;
1084
+ canCreateIssues?: boolean;
1082
1085
  avatarSeed?: string;
1083
1086
  runtimeCommand?: string | null;
1084
1087
  runtimeArgsJson?: string;
@@ -1111,7 +1114,10 @@ export async function createAgent(
1111
1114
  heartbeatCron: input.heartbeatCron,
1112
1115
  monthlyBudgetUsd: input.monthlyBudgetUsd,
1113
1116
  canHireAgents: input.canHireAgents ?? false,
1117
+ canAssignAgents: input.canAssignAgents ?? true,
1118
+ canCreateIssues: input.canCreateIssues ?? true,
1114
1119
  avatarSeed,
1120
+ lucideIconName: "",
1115
1121
  runtimeCommand: input.runtimeCommand ?? null,
1116
1122
  runtimeArgsJson: input.runtimeArgsJson ?? "[]",
1117
1123
  runtimeCwd: input.runtimeCwd ?? null,
@@ -1132,6 +1138,15 @@ export async function listAgents(db: BopoDb, companyId: string) {
1132
1138
  return db.select().from(agents).where(eq(agents.companyId, companyId)).orderBy(desc(agents.createdAt));
1133
1139
  }
1134
1140
 
1141
+ export async function getCompanyAgent(db: BopoDb, companyId: string, agentId: string) {
1142
+ const [row] = await db
1143
+ .select()
1144
+ .from(agents)
1145
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
1146
+ .limit(1);
1147
+ return row ?? null;
1148
+ }
1149
+
1135
1150
  export async function updateAgent(
1136
1151
  db: BopoDb,
1137
1152
  input: {
@@ -1158,6 +1173,8 @@ export async function updateAgent(
1158
1173
  heartbeatCron?: string;
1159
1174
  monthlyBudgetUsd?: string;
1160
1175
  canHireAgents?: boolean;
1176
+ canAssignAgents?: boolean;
1177
+ canCreateIssues?: boolean;
1161
1178
  runtimeCommand?: string | null;
1162
1179
  runtimeArgsJson?: string;
1163
1180
  runtimeCwd?: string | null;
@@ -1169,6 +1186,8 @@ export async function updateAgent(
1169
1186
  interruptGraceSec?: number;
1170
1187
  runPolicyJson?: string;
1171
1188
  stateBlob?: Record<string, unknown>;
1189
+ lucideIconName?: string;
1190
+ avatarSeed?: string;
1172
1191
  }
1173
1192
  ) {
1174
1193
  if (input.managerAgentId) {
@@ -1189,6 +1208,10 @@ export async function updateAgent(
1189
1208
  heartbeatCron: input.heartbeatCron,
1190
1209
  monthlyBudgetUsd: input.monthlyBudgetUsd,
1191
1210
  canHireAgents: input.canHireAgents,
1211
+ canAssignAgents: input.canAssignAgents,
1212
+ canCreateIssues: input.canCreateIssues,
1213
+ lucideIconName: input.lucideIconName,
1214
+ avatarSeed: input.avatarSeed,
1192
1215
  runtimeCommand: input.runtimeCommand,
1193
1216
  runtimeArgsJson: input.runtimeArgsJson,
1194
1217
  runtimeCwd: input.runtimeCwd,
@@ -2303,6 +2326,122 @@ export async function appendPluginRun(
2303
2326
  return id;
2304
2327
  }
2305
2328
 
2329
+ export async function appendPluginInstall(
2330
+ db: BopoDb,
2331
+ input: {
2332
+ companyId: string;
2333
+ pluginId: string;
2334
+ pluginVersion: string;
2335
+ sourceType: string;
2336
+ sourceRef?: string | null;
2337
+ integrity?: string | null;
2338
+ buildHash?: string | null;
2339
+ artifactPath?: string | null;
2340
+ manifestJson: string;
2341
+ status?: "active" | "superseded" | "rolled_back";
2342
+ }
2343
+ ) {
2344
+ const id = nanoid(14);
2345
+ await db.insert(pluginInstalls).values({
2346
+ id,
2347
+ companyId: input.companyId,
2348
+ pluginId: input.pluginId,
2349
+ pluginVersion: input.pluginVersion,
2350
+ sourceType: input.sourceType,
2351
+ sourceRef: input.sourceRef ?? null,
2352
+ integrity: input.integrity ?? null,
2353
+ buildHash: input.buildHash ?? null,
2354
+ artifactPath: input.artifactPath ?? null,
2355
+ manifestJson: input.manifestJson,
2356
+ status: input.status ?? "active"
2357
+ });
2358
+ return id;
2359
+ }
2360
+
2361
+ export async function listPluginInstalls(
2362
+ db: BopoDb,
2363
+ input: {
2364
+ companyId: string;
2365
+ pluginId: string;
2366
+ limit?: number;
2367
+ }
2368
+ ) {
2369
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
2370
+ return db
2371
+ .select()
2372
+ .from(pluginInstalls)
2373
+ .where(and(eq(pluginInstalls.companyId, input.companyId), eq(pluginInstalls.pluginId, input.pluginId)))
2374
+ .orderBy(desc(pluginInstalls.createdAt))
2375
+ .limit(limit);
2376
+ }
2377
+
2378
+ /** Per-plugin counts of `plugin_installs` rows for rollback UX (need ≥2 revisions to roll back). */
2379
+ export async function countPluginInstallRevisionsByCompany(db: BopoDb, companyId: string): Promise<Map<string, number>> {
2380
+ const rows = await db
2381
+ .select({
2382
+ pluginId: pluginInstalls.pluginId,
2383
+ revisionCount: count()
2384
+ })
2385
+ .from(pluginInstalls)
2386
+ .where(eq(pluginInstalls.companyId, companyId))
2387
+ .groupBy(pluginInstalls.pluginId);
2388
+ return new Map(rows.map((row) => [row.pluginId, Number(row.revisionCount)]));
2389
+ }
2390
+
2391
+ export async function getPluginInstallById(
2392
+ db: BopoDb,
2393
+ input: {
2394
+ companyId: string;
2395
+ pluginId: string;
2396
+ installId: string;
2397
+ }
2398
+ ) {
2399
+ const [row] = await db
2400
+ .select()
2401
+ .from(pluginInstalls)
2402
+ .where(
2403
+ and(
2404
+ eq(pluginInstalls.companyId, input.companyId),
2405
+ eq(pluginInstalls.pluginId, input.pluginId),
2406
+ eq(pluginInstalls.id, input.installId)
2407
+ )
2408
+ )
2409
+ .limit(1);
2410
+ return row ?? null;
2411
+ }
2412
+
2413
+ export async function markPluginInstallsSuperseded(db: BopoDb, input: { companyId: string; pluginId: string }) {
2414
+ await db
2415
+ .update(pluginInstalls)
2416
+ .set({
2417
+ status: "superseded"
2418
+ })
2419
+ .where(and(eq(pluginInstalls.companyId, input.companyId), eq(pluginInstalls.pluginId, input.pluginId), eq(pluginInstalls.status, "active")));
2420
+ }
2421
+
2422
+ export async function markPluginInstallStatus(
2423
+ db: BopoDb,
2424
+ input: {
2425
+ companyId: string;
2426
+ pluginId: string;
2427
+ installId: string;
2428
+ status: "active" | "superseded" | "rolled_back";
2429
+ }
2430
+ ) {
2431
+ await db
2432
+ .update(pluginInstalls)
2433
+ .set({
2434
+ status: input.status
2435
+ })
2436
+ .where(
2437
+ and(
2438
+ eq(pluginInstalls.companyId, input.companyId),
2439
+ eq(pluginInstalls.pluginId, input.pluginId),
2440
+ eq(pluginInstalls.id, input.installId)
2441
+ )
2442
+ );
2443
+ }
2444
+
2306
2445
  export async function listPluginRuns(
2307
2446
  db: BopoDb,
2308
2447
  input: { companyId: string; pluginId?: string; runId?: string; limit?: number }
package/src/schema.ts CHANGED
@@ -86,7 +86,10 @@ export const agents = pgTable("agents", {
86
86
  .default("0"),
87
87
  tokenUsage: integer("token_usage").notNull().default(0),
88
88
  canHireAgents: boolean("can_hire_agents").notNull().default(false),
89
+ canAssignAgents: boolean("can_assign_agents").notNull().default(true),
90
+ canCreateIssues: boolean("can_create_issues").notNull().default(true),
89
91
  avatarSeed: text("avatar_seed").notNull().default(""),
92
+ lucideIconName: text("lucide_icon_name").notNull().default(""),
90
93
  runtimeCommand: text("runtime_command"),
91
94
  runtimeArgsJson: text("runtime_args_json").notNull().default("[]"),
92
95
  runtimeCwd: text("runtime_cwd"),
@@ -122,9 +125,9 @@ export const issues = pgTable("issues", {
122
125
  externalLink: text("external_link"),
123
126
  isClaimed: boolean("is_claimed").notNull().default(false),
124
127
  claimedByHeartbeatRunId: text("claimed_by_heartbeat_run_id"),
125
- /** Set when issue was created by a scheduled/manual work loop run (FK enforced in SQL migration). */
126
- loopId: text("loop_id"),
127
- loopRunId: text("loop_run_id"),
128
+ /** Set when issue was created by a scheduled/manual routine run (FK enforced in SQL migration). */
129
+ routineId: text("loop_id"),
130
+ routineRunId: text("loop_run_id"),
128
131
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
129
132
  updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
130
133
  });
@@ -158,7 +161,7 @@ export const workLoopTriggers = pgTable("work_loop_triggers", {
158
161
  companyId: text("company_id")
159
162
  .notNull()
160
163
  .references(() => companies.id, { onDelete: "cascade" }),
161
- workLoopId: text("work_loop_id")
164
+ routineId: text("work_loop_id")
162
165
  .notNull()
163
166
  .references(() => workLoops.id, { onDelete: "cascade" }),
164
167
  kind: text("kind").notNull().default("schedule"),
@@ -178,7 +181,7 @@ export const workLoopRuns = pgTable("work_loop_runs", {
178
181
  companyId: text("company_id")
179
182
  .notNull()
180
183
  .references(() => companies.id, { onDelete: "cascade" }),
181
- workLoopId: text("work_loop_id")
184
+ routineId: text("work_loop_id")
182
185
  .notNull()
183
186
  .references(() => workLoops.id, { onDelete: "cascade" }),
184
187
  triggerId: text("trigger_id").references(() => workLoopTriggers.id, { onDelete: "set null" }),
@@ -522,6 +525,25 @@ export const pluginRuns = pgTable("plugin_runs", {
522
525
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
523
526
  });
524
527
 
528
+ export const pluginInstalls = pgTable("plugin_installs", {
529
+ id: text("id").primaryKey(),
530
+ companyId: text("company_id")
531
+ .notNull()
532
+ .references(() => companies.id, { onDelete: "cascade" }),
533
+ pluginId: text("plugin_id")
534
+ .notNull()
535
+ .references(() => plugins.id, { onDelete: "cascade" }),
536
+ pluginVersion: text("plugin_version").notNull(),
537
+ sourceType: text("source_type").notNull().default("registry"),
538
+ sourceRef: text("source_ref"),
539
+ integrity: text("integrity"),
540
+ buildHash: text("build_hash"),
541
+ artifactPath: text("artifact_path"),
542
+ manifestJson: text("manifest_json").notNull().default("{}"),
543
+ status: text("status").notNull().default("active"),
544
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
545
+ });
546
+
525
547
  export const agentIssueLabels = pgTable(
526
548
  "agent_issue_labels",
527
549
  {
@@ -560,6 +582,7 @@ export const schema = {
560
582
  plugins,
561
583
  pluginConfigs,
562
584
  pluginRuns,
585
+ pluginInstalls,
563
586
  templates,
564
587
  templateVersions,
565
588
  templateInstalls,