mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Merge remote-tracking branch 'public-gh/master' into pap-1167-mcp-server-package
* public-gh/master: (51 commits) test(cli): align env input fixtures with project scope fix(export): strip project env values from company packages fix(ui): address review follow-ups fix(runtime): handle empty dev runner responses fix(ui): remove runtime-only preflight hook dependency test(ui): wait for async issue search results refactor(ui): inline document diff rendering test(cli): keep import preview fixtures aligned with manifest shape test(cli): cover project env in import preview fixtures fix(ui): restore attachment delete state hook order Speed up issue search Narrow parent issue and time-ago columns in inbox grid Add optional Parent Issue column to inbox show/hide columns Move sub-issues inline and remove sub-issues tab Display image attachments as square-cropped gallery grid Offset scroll-to-bottom button when properties panel is open Polish board approval card styling Default sub-issues to parent workspace Relax sub-issue dialog banner layout Improve issue approval visibility ...
This commit is contained in:
commit
2329a33f32
92 changed files with 31524 additions and 806 deletions
|
|
@ -176,4 +176,49 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"restores statements incrementally when backup comments precede the first breakpoint",
|
||||
async () => {
|
||||
const restoreConnectionString = await createTempDatabase();
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
const backupDir = createTempDir("paperclip-db-restore-manual-");
|
||||
const backupFile = path.join(backupDir, "manual.sql");
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
backupFile,
|
||||
[
|
||||
"-- Paperclip database backup",
|
||||
"-- Created: 2026-04-06T00:00:00.000Z",
|
||||
"",
|
||||
"BEGIN;",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"CREATE TABLE public.restore_stream_test (id integer primary key, payload text not null);",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"INSERT INTO public.restore_stream_test (id, payload)",
|
||||
"VALUES (1, 'hello');",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
"COMMIT;",
|
||||
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile,
|
||||
});
|
||||
|
||||
const rows = await restoreSql.unsafe<{ payload: string }[]>(`
|
||||
SELECT payload
|
||||
FROM public.restore_stream_test
|
||||
`);
|
||||
expect(rows).toEqual([{ payload: "hello" }]);
|
||||
} finally {
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
|
|
@ -45,6 +45,11 @@ type TableDefinition = {
|
|||
tablename: string;
|
||||
};
|
||||
|
||||
type ExtensionDefinition = {
|
||||
extension_name: string;
|
||||
schema_name: string;
|
||||
};
|
||||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||
|
|
@ -142,6 +147,42 @@ function tableKey(schemaName: string, tableName: string): string {
|
|||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
||||
const stream = createReadStream(backupFile, { encoding: "utf8" });
|
||||
const reader = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
let statementLines: string[] = [];
|
||||
|
||||
const flushStatement = () => {
|
||||
const statement = statementLines.join("\n").trim();
|
||||
statementLines = [];
|
||||
return statement;
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const line of reader) {
|
||||
if (line === STATEMENT_BREAKPOINT) {
|
||||
const statement = flushStatement();
|
||||
if (statement.length > 0) {
|
||||
yield statement;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
statementLines.push(line);
|
||||
}
|
||||
|
||||
const trailingStatement = flushStatement();
|
||||
if (trailingStatement.length > 0) {
|
||||
yield trailingStatement;
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
|
||||
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
||||
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
||||
|
|
@ -340,6 +381,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emit("");
|
||||
}
|
||||
|
||||
const extensions = await sql<ExtensionDefinition[]>`
|
||||
SELECT
|
||||
e.extname AS extension_name,
|
||||
n.nspname AS schema_name
|
||||
FROM pg_extension e
|
||||
JOIN pg_namespace n ON n.oid = e.extnamespace
|
||||
WHERE e.extname <> 'plpgsql'
|
||||
ORDER BY e.extname
|
||||
`;
|
||||
if (extensions.length > 0) {
|
||||
emit("-- Extensions");
|
||||
for (const extension of extensions) {
|
||||
emitStatement(
|
||||
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequences");
|
||||
for (const seq of sequences) {
|
||||
|
|
@ -626,13 +686,7 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi
|
|||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
const contents = await readFile(opts.backupFile, "utf8");
|
||||
const statements = contents
|
||||
.split(STATEMENT_BREAKPOINT)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
for await (const statement of readRestoreStatements(opts.backupFile)) {
|
||||
await sql.unsafe(statement).execute();
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -401,4 +401,70 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0050 safely when projects.env already exists",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const stiffLuckmanHash = await migrationHash("0050_stiff_luckman.sql");
|
||||
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${stiffLuckmanHash}'`,
|
||||
);
|
||||
|
||||
const columns = await sql.unsafe<{ column_name: string }[]>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'projects'
|
||||
AND column_name = 'env'
|
||||
`,
|
||||
);
|
||||
expect(columns).toHaveLength(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0050_stiff_luckman.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>(
|
||||
`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'projects'
|
||||
AND column_name = 'env'
|
||||
`,
|
||||
);
|
||||
expect(columns).toEqual([
|
||||
expect.objectContaining({
|
||||
column_name: "env",
|
||||
is_nullable: "YES",
|
||||
data_type: "jsonb",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
1
packages/db/src/migrations/0050_stiff_luckman.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;
|
||||
5
packages/db/src/migrations/0051_young_korg.sql
Normal file
5
packages/db/src/migrations/0051_young_korg.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
|
||||
CREATE INDEX "issue_comments_body_search_idx" ON "issue_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "issues_title_search_idx" ON "issues" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "issues_identifier_search_idx" ON "issues" USING gin ("identifier" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "issues_description_search_idx" ON "issues" USING gin ("description" gin_trgm_ops);
|
||||
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
12772
packages/db/src/migrations/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
12836
packages/db/src/migrations/meta/0051_snapshot.json
Normal file
12836
packages/db/src/migrations/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -351,6 +351,20 @@
|
|||
"when": 1775349863293,
|
||||
"tag": "0049_flawless_abomination",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "7",
|
||||
"when": 1775487782768,
|
||||
"tag": "0050_stiff_luckman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "7",
|
||||
"when": 1775524651831,
|
||||
"tag": "0051_young_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@ export const issueComments = pgTable(
|
|||
table.issueId,
|
||||
table.createdAt,
|
||||
),
|
||||
bodySearchIdx: index("issue_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export const issues = pgTable(
|
|||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
||||
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
||||
descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")),
|
||||
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
||||
.on(table.companyId, table.originKind, table.originId)
|
||||
.where(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
|
||||
import type { AgentEnvConfig } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
|
@ -15,6 +16,7 @@ export const projects = pgTable(
|
|||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||
|
|
|
|||
|
|
@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
|
|||
"#3b82f6", // blue
|
||||
] as const;
|
||||
|
||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
|
||||
export const APPROVAL_TYPES = [
|
||||
"hire_agent",
|
||||
"approve_ceo_strategy",
|
||||
"budget_override_required",
|
||||
"request_board_approval",
|
||||
] as const;
|
||||
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||
|
||||
export const APPROVAL_STATUSES = [
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
|
||||
export interface CompanyPortabilityInclude {
|
||||
company: boolean;
|
||||
agents: boolean;
|
||||
|
|
@ -10,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
|
|||
key: string;
|
||||
description: string | null;
|
||||
agentSlug: string | null;
|
||||
projectSlug: string | null;
|
||||
kind: "secret" | "plain";
|
||||
requirement: "required" | "optional";
|
||||
defaultValue: string | null;
|
||||
|
|
@ -52,13 +56,12 @@ export interface CompanyPortabilityProjectManifestEntry {
|
|||
targetDate: string | null;
|
||||
color: string | null;
|
||||
status: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
|
||||
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type {
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
|
|
@ -65,6 +66,7 @@ export interface Project {
|
|||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
|
|||
key: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
agentSlug: z.string().min(1).nullable(),
|
||||
projectSlug: z.string().min(1).nullable(),
|
||||
kind: z.enum(["secret", "plain"]),
|
||||
requirement: z.enum(["required", "optional"]),
|
||||
defaultValue: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { PROJECT_STATUSES } from "../constants.js";
|
||||
import { envConfigSchema } from "./secret.js";
|
||||
|
||||
const executionWorkspaceStrategySchema = z
|
||||
.object({
|
||||
|
|
@ -102,6 +103,7 @@ const projectFields = {
|
|||
leadAgentId: z.string().uuid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
color: z.string().optional().nullable(),
|
||||
env: envConfigSchema.optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue