bopodev-db 0.1.34 → 0.1.36

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.
@@ -0,0 +1,42 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { config as loadDotenv } from "dotenv";
5
+
6
+ /**
7
+ * Load the same env files as `apps/api` (`loadApiEnv`), so `db:migrate` targets the DB
8
+ * the API uses when developers run migrations from the shell without exporting variables.
9
+ */
10
+ export function loadMigrateEnv() {
11
+ const roots = resolveCandidateRepoRoots();
12
+ for (const root of roots) {
13
+ for (const name of [".env.local", ".env"] as const) {
14
+ loadDotenv({ path: join(root, name), override: false, quiet: true });
15
+ }
16
+ }
17
+ }
18
+
19
+ function resolveCandidateRepoRoots(): string[] {
20
+ const fromPackage = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
21
+ const ordered: string[] = [fromPackage];
22
+ const fromCwd = findRepoRootWalkingFromCwd();
23
+ if (fromCwd && resolve(fromCwd) !== resolve(fromPackage)) {
24
+ ordered.push(fromCwd);
25
+ }
26
+ return ordered;
27
+ }
28
+
29
+ function findRepoRootWalkingFromCwd(): string | null {
30
+ let dir = resolve(process.cwd());
31
+ for (let i = 0; i < 12; i++) {
32
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) {
33
+ return dir;
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir) {
37
+ break;
38
+ }
39
+ dir = parent;
40
+ }
41
+ return null;
42
+ }
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");
@@ -0,0 +1 @@
1
+ ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "knowledge_paths_json" text NOT NULL DEFAULT '[]';
@@ -64,6 +64,34 @@
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
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "7",
92
+ "when": 1743700000000,
93
+ "tag": "0012_issue_knowledge_paths",
94
+ "breakpoints": true
67
95
  }
68
96
  ]
69
97
  }
@@ -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,9 @@ 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
+ knowledgePaths?: string[];
526
+ routineId?: string | null;
527
+ routineRunId?: string | null;
526
528
  }
527
529
  ) {
528
530
  await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
@@ -549,8 +551,9 @@ export async function createIssue(
549
551
  assigneeAgentId: input.assigneeAgentId ?? null,
550
552
  labelsJson: JSON.stringify(input.labels ?? []),
551
553
  tagsJson: JSON.stringify(input.tags ?? []),
552
- loopId: input.loopId ?? null,
553
- loopRunId: input.loopRunId ?? null
554
+ knowledgePathsJson: JSON.stringify(input.knowledgePaths ?? []),
555
+ routineId: input.routineId ?? null,
556
+ routineRunId: input.routineRunId ?? null
554
557
  })
555
558
  .returning();
556
559
  if (!row) {
@@ -581,6 +584,7 @@ export async function updateIssue(
581
584
  assigneeAgentId?: string | null;
582
585
  labels?: string[];
583
586
  tags?: string[];
587
+ knowledgePaths?: string[];
584
588
  }
585
589
  ) {
586
590
  const [existing] = await db
@@ -615,6 +619,8 @@ export async function updateIssue(
615
619
  assigneeAgentId: input.assigneeAgentId,
616
620
  labelsJson: input.labels ? JSON.stringify(input.labels) : undefined,
617
621
  tagsJson: input.tags ? JSON.stringify(input.tags) : undefined,
622
+ knowledgePathsJson:
623
+ input.knowledgePaths !== undefined ? JSON.stringify(input.knowledgePaths) : undefined,
618
624
  updatedAt: touchUpdatedAtSql
619
625
  })
620
626
  )
@@ -1079,6 +1085,8 @@ export async function createAgent(
1079
1085
  heartbeatCron: string;
1080
1086
  monthlyBudgetUsd: string;
1081
1087
  canHireAgents?: boolean;
1088
+ canAssignAgents?: boolean;
1089
+ canCreateIssues?: boolean;
1082
1090
  avatarSeed?: string;
1083
1091
  runtimeCommand?: string | null;
1084
1092
  runtimeArgsJson?: string;
@@ -1111,7 +1119,10 @@ export async function createAgent(
1111
1119
  heartbeatCron: input.heartbeatCron,
1112
1120
  monthlyBudgetUsd: input.monthlyBudgetUsd,
1113
1121
  canHireAgents: input.canHireAgents ?? false,
1122
+ canAssignAgents: input.canAssignAgents ?? true,
1123
+ canCreateIssues: input.canCreateIssues ?? true,
1114
1124
  avatarSeed,
1125
+ lucideIconName: "",
1115
1126
  runtimeCommand: input.runtimeCommand ?? null,
1116
1127
  runtimeArgsJson: input.runtimeArgsJson ?? "[]",
1117
1128
  runtimeCwd: input.runtimeCwd ?? null,
@@ -1132,6 +1143,15 @@ export async function listAgents(db: BopoDb, companyId: string) {
1132
1143
  return db.select().from(agents).where(eq(agents.companyId, companyId)).orderBy(desc(agents.createdAt));
1133
1144
  }
1134
1145
 
1146
+ export async function getCompanyAgent(db: BopoDb, companyId: string, agentId: string) {
1147
+ const [row] = await db
1148
+ .select()
1149
+ .from(agents)
1150
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
1151
+ .limit(1);
1152
+ return row ?? null;
1153
+ }
1154
+
1135
1155
  export async function updateAgent(
1136
1156
  db: BopoDb,
1137
1157
  input: {
@@ -1158,6 +1178,8 @@ export async function updateAgent(
1158
1178
  heartbeatCron?: string;
1159
1179
  monthlyBudgetUsd?: string;
1160
1180
  canHireAgents?: boolean;
1181
+ canAssignAgents?: boolean;
1182
+ canCreateIssues?: boolean;
1161
1183
  runtimeCommand?: string | null;
1162
1184
  runtimeArgsJson?: string;
1163
1185
  runtimeCwd?: string | null;
@@ -1169,6 +1191,8 @@ export async function updateAgent(
1169
1191
  interruptGraceSec?: number;
1170
1192
  runPolicyJson?: string;
1171
1193
  stateBlob?: Record<string, unknown>;
1194
+ lucideIconName?: string;
1195
+ avatarSeed?: string;
1172
1196
  }
1173
1197
  ) {
1174
1198
  if (input.managerAgentId) {
@@ -1189,6 +1213,10 @@ export async function updateAgent(
1189
1213
  heartbeatCron: input.heartbeatCron,
1190
1214
  monthlyBudgetUsd: input.monthlyBudgetUsd,
1191
1215
  canHireAgents: input.canHireAgents,
1216
+ canAssignAgents: input.canAssignAgents,
1217
+ canCreateIssues: input.canCreateIssues,
1218
+ lucideIconName: input.lucideIconName,
1219
+ avatarSeed: input.avatarSeed,
1192
1220
  runtimeCommand: input.runtimeCommand,
1193
1221
  runtimeArgsJson: input.runtimeArgsJson,
1194
1222
  runtimeCwd: input.runtimeCwd,
@@ -2303,6 +2331,122 @@ export async function appendPluginRun(
2303
2331
  return id;
2304
2332
  }
2305
2333
 
2334
+ export async function appendPluginInstall(
2335
+ db: BopoDb,
2336
+ input: {
2337
+ companyId: string;
2338
+ pluginId: string;
2339
+ pluginVersion: string;
2340
+ sourceType: string;
2341
+ sourceRef?: string | null;
2342
+ integrity?: string | null;
2343
+ buildHash?: string | null;
2344
+ artifactPath?: string | null;
2345
+ manifestJson: string;
2346
+ status?: "active" | "superseded" | "rolled_back";
2347
+ }
2348
+ ) {
2349
+ const id = nanoid(14);
2350
+ await db.insert(pluginInstalls).values({
2351
+ id,
2352
+ companyId: input.companyId,
2353
+ pluginId: input.pluginId,
2354
+ pluginVersion: input.pluginVersion,
2355
+ sourceType: input.sourceType,
2356
+ sourceRef: input.sourceRef ?? null,
2357
+ integrity: input.integrity ?? null,
2358
+ buildHash: input.buildHash ?? null,
2359
+ artifactPath: input.artifactPath ?? null,
2360
+ manifestJson: input.manifestJson,
2361
+ status: input.status ?? "active"
2362
+ });
2363
+ return id;
2364
+ }
2365
+
2366
+ export async function listPluginInstalls(
2367
+ db: BopoDb,
2368
+ input: {
2369
+ companyId: string;
2370
+ pluginId: string;
2371
+ limit?: number;
2372
+ }
2373
+ ) {
2374
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
2375
+ return db
2376
+ .select()
2377
+ .from(pluginInstalls)
2378
+ .where(and(eq(pluginInstalls.companyId, input.companyId), eq(pluginInstalls.pluginId, input.pluginId)))
2379
+ .orderBy(desc(pluginInstalls.createdAt))
2380
+ .limit(limit);
2381
+ }
2382
+
2383
+ /** Per-plugin counts of `plugin_installs` rows for rollback UX (need ≥2 revisions to roll back). */
2384
+ export async function countPluginInstallRevisionsByCompany(db: BopoDb, companyId: string): Promise<Map<string, number>> {
2385
+ const rows = await db
2386
+ .select({
2387
+ pluginId: pluginInstalls.pluginId,
2388
+ revisionCount: count()
2389
+ })
2390
+ .from(pluginInstalls)
2391
+ .where(eq(pluginInstalls.companyId, companyId))
2392
+ .groupBy(pluginInstalls.pluginId);
2393
+ return new Map(rows.map((row) => [row.pluginId, Number(row.revisionCount)]));
2394
+ }
2395
+
2396
+ export async function getPluginInstallById(
2397
+ db: BopoDb,
2398
+ input: {
2399
+ companyId: string;
2400
+ pluginId: string;
2401
+ installId: string;
2402
+ }
2403
+ ) {
2404
+ const [row] = await db
2405
+ .select()
2406
+ .from(pluginInstalls)
2407
+ .where(
2408
+ and(
2409
+ eq(pluginInstalls.companyId, input.companyId),
2410
+ eq(pluginInstalls.pluginId, input.pluginId),
2411
+ eq(pluginInstalls.id, input.installId)
2412
+ )
2413
+ )
2414
+ .limit(1);
2415
+ return row ?? null;
2416
+ }
2417
+
2418
+ export async function markPluginInstallsSuperseded(db: BopoDb, input: { companyId: string; pluginId: string }) {
2419
+ await db
2420
+ .update(pluginInstalls)
2421
+ .set({
2422
+ status: "superseded"
2423
+ })
2424
+ .where(and(eq(pluginInstalls.companyId, input.companyId), eq(pluginInstalls.pluginId, input.pluginId), eq(pluginInstalls.status, "active")));
2425
+ }
2426
+
2427
+ export async function markPluginInstallStatus(
2428
+ db: BopoDb,
2429
+ input: {
2430
+ companyId: string;
2431
+ pluginId: string;
2432
+ installId: string;
2433
+ status: "active" | "superseded" | "rolled_back";
2434
+ }
2435
+ ) {
2436
+ await db
2437
+ .update(pluginInstalls)
2438
+ .set({
2439
+ status: input.status
2440
+ })
2441
+ .where(
2442
+ and(
2443
+ eq(pluginInstalls.companyId, input.companyId),
2444
+ eq(pluginInstalls.pluginId, input.pluginId),
2445
+ eq(pluginInstalls.id, input.installId)
2446
+ )
2447
+ );
2448
+ }
2449
+
2306
2450
  export async function listPluginRuns(
2307
2451
  db: BopoDb,
2308
2452
  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"),
@@ -118,13 +121,15 @@ export const issues = pgTable("issues", {
118
121
  assigneeAgentId: text("assignee_agent_id"),
119
122
  labelsJson: text("labels_json").notNull().default("[]"),
120
123
  tagsJson: text("tags_json").notNull().default("[]"),
124
+ /** Relative paths under company workspace `knowledge/` linked from this issue. */
125
+ knowledgePathsJson: text("knowledge_paths_json").notNull().default("[]"),
121
126
  /** Optional link to a PR, branch page, or other external tracker (GitHub/GitLab/etc.). */
122
127
  externalLink: text("external_link"),
123
128
  isClaimed: boolean("is_claimed").notNull().default(false),
124
129
  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"),
130
+ /** Set when issue was created by a scheduled/manual routine run (FK enforced in SQL migration). */
131
+ routineId: text("loop_id"),
132
+ routineRunId: text("loop_run_id"),
128
133
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
129
134
  updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
130
135
  });
@@ -158,7 +163,7 @@ export const workLoopTriggers = pgTable("work_loop_triggers", {
158
163
  companyId: text("company_id")
159
164
  .notNull()
160
165
  .references(() => companies.id, { onDelete: "cascade" }),
161
- workLoopId: text("work_loop_id")
166
+ routineId: text("work_loop_id")
162
167
  .notNull()
163
168
  .references(() => workLoops.id, { onDelete: "cascade" }),
164
169
  kind: text("kind").notNull().default("schedule"),
@@ -178,7 +183,7 @@ export const workLoopRuns = pgTable("work_loop_runs", {
178
183
  companyId: text("company_id")
179
184
  .notNull()
180
185
  .references(() => companies.id, { onDelete: "cascade" }),
181
- workLoopId: text("work_loop_id")
186
+ routineId: text("work_loop_id")
182
187
  .notNull()
183
188
  .references(() => workLoops.id, { onDelete: "cascade" }),
184
189
  triggerId: text("trigger_id").references(() => workLoopTriggers.id, { onDelete: "set null" }),
@@ -522,6 +527,25 @@ export const pluginRuns = pgTable("plugin_runs", {
522
527
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
523
528
  });
524
529
 
530
+ export const pluginInstalls = pgTable("plugin_installs", {
531
+ id: text("id").primaryKey(),
532
+ companyId: text("company_id")
533
+ .notNull()
534
+ .references(() => companies.id, { onDelete: "cascade" }),
535
+ pluginId: text("plugin_id")
536
+ .notNull()
537
+ .references(() => plugins.id, { onDelete: "cascade" }),
538
+ pluginVersion: text("plugin_version").notNull(),
539
+ sourceType: text("source_type").notNull().default("registry"),
540
+ sourceRef: text("source_ref"),
541
+ integrity: text("integrity"),
542
+ buildHash: text("build_hash"),
543
+ artifactPath: text("artifact_path"),
544
+ manifestJson: text("manifest_json").notNull().default("{}"),
545
+ status: text("status").notNull().default("active"),
546
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
547
+ });
548
+
525
549
  export const agentIssueLabels = pgTable(
526
550
  "agent_issue_labels",
527
551
  {
@@ -560,6 +584,7 @@ export const schema = {
560
584
  plugins,
561
585
  pluginConfigs,
562
586
  pluginRuns,
587
+ pluginInstalls,
563
588
  templates,
564
589
  templateVersions,
565
590
  templateInstalls,