feat: add storage system with local disk and S3 providers

Introduces a provider-agnostic storage subsystem for file attachments.
Includes local disk and S3 backends, asset/attachment DB schemas, issue
attachment CRUD routes with multer upload, CLI configure/doctor/env
integration, and enriched issue ancestors with project/goal resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 10:31:56 -06:00
parent 32119f5c2f
commit fdd2ea6157
36 changed files with 1683 additions and 32 deletions

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { SECRET_PROVIDERS } from "./constants.js";
import { SECRET_PROVIDERS, STORAGE_PROVIDERS } from "./constants.js";
export const configMetaSchema = z.object({
version: z.literal(1),
@@ -29,6 +29,31 @@ export const serverConfigSchema = z.object({
serveUi: z.boolean().default(true),
});
export const storageLocalDiskConfigSchema = z.object({
baseDir: z.string().default("~/.paperclip/instances/default/data/storage"),
});
export const storageS3ConfigSchema = z.object({
bucket: z.string().min(1).default("paperclip"),
region: z.string().min(1).default("us-east-1"),
endpoint: z.string().optional(),
prefix: z.string().default(""),
forcePathStyle: z.boolean().default(false),
});
export const storageConfigSchema = z.object({
provider: z.enum(STORAGE_PROVIDERS).default("local_disk"),
localDisk: storageLocalDiskConfigSchema.default({
baseDir: "~/.paperclip/instances/default/data/storage",
}),
s3: storageS3ConfigSchema.default({
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
}),
});
export const secretsLocalEncryptedConfigSchema = z.object({
keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"),
});
@@ -47,6 +72,18 @@ export const paperclipConfigSchema = z.object({
database: databaseConfigSchema,
logging: loggingConfigSchema,
server: serverConfigSchema,
storage: storageConfigSchema.default({
provider: "local_disk",
localDisk: {
baseDir: "~/.paperclip/instances/default/data/storage",
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
}),
secrets: secretsConfigSchema.default({
provider: "local_encrypted",
strictMode: false,
@@ -61,6 +98,9 @@ export type LlmConfig = z.infer<typeof llmConfigSchema>;
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>;
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
export type ServerConfig = z.infer<typeof serverConfigSchema>;
export type StorageConfig = z.infer<typeof storageConfigSchema>;
export type StorageLocalDiskConfig = z.infer<typeof storageLocalDiskConfigSchema>;
export type StorageS3Config = z.infer<typeof storageS3ConfigSchema>;
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
export type ConfigMeta = z.infer<typeof configMetaSchema>;

View File

@@ -79,6 +79,9 @@ export const SECRET_PROVIDERS = [
] as const;
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
export const HEARTBEAT_INVOCATION_SOURCES = [
"timer",
"assignment",

View File

@@ -11,6 +11,7 @@ export {
APPROVAL_TYPES,
APPROVAL_STATUSES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
HEARTBEAT_INVOCATION_SOURCES,
HEARTBEAT_RUN_STATUSES,
WAKEUP_TRIGGER_DETAILS,
@@ -28,6 +29,7 @@ export {
type ApprovalType,
type ApprovalStatus,
type SecretProvider,
type StorageProvider,
type HeartbeatInvocationSource,
type HeartbeatRunStatus,
type WakeupTriggerDetail,
@@ -44,6 +46,7 @@ export type {
Project,
Issue,
IssueComment,
IssueAttachment,
Goal,
Approval,
ApprovalComment,
@@ -94,11 +97,13 @@ export {
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
type CreateIssue,
type UpdateIssue,
type CheckoutIssue,
type AddIssueComment,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
createGoalSchema,
updateGoalSchema,
type CreateGoal,
@@ -139,12 +144,18 @@ export {
loggingConfigSchema,
serverConfigSchema,
secretsConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
secretsLocalEncryptedConfigSchema,
type PaperclipConfig,
type LlmConfig,
type DatabaseConfig,
type LoggingConfig,
type ServerConfig,
type StorageConfig,
type StorageLocalDiskConfig,
type StorageS3Config,
type SecretsConfig,
type SecretsLocalEncryptedConfig,
type ConfigMeta,

View File

@@ -1,7 +1,14 @@
export type { Company } from "./company.js";
export type { Agent, AgentPermissions, AgentKeyCreated, AgentConfigRevision } from "./agent.js";
export type { Project } from "./project.js";
export type { Issue, IssueComment, IssueAncestor } from "./issue.js";
export type {
Issue,
IssueComment,
IssueAncestor,
IssueAncestorProject,
IssueAncestorGoal,
IssueAttachment,
} from "./issue.js";
export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";
export type {

View File

@@ -1,5 +1,21 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
export interface IssueAncestorProject {
id: string;
name: string;
description: string | null;
status: string;
goalId: string | null;
}
export interface IssueAncestorGoal {
id: string;
title: string;
description: string | null;
level: string;
status: string;
}
export interface IssueAncestor {
id: string;
title: string;
@@ -9,6 +25,8 @@ export interface IssueAncestor {
assigneeAgentId: string | null;
projectId: string | null;
goalId: string | null;
project: IssueAncestorProject | null;
goal: IssueAncestorGoal | null;
}
export interface Issue {
@@ -47,3 +65,22 @@ export interface IssueComment {
createdAt: Date;
updatedAt: Date;
}
export interface IssueAttachment {
id: string;
companyId: string;
issueId: string;
issueCommentId: string | null;
assetId: string;
provider: string;
objectKey: string;
contentType: string;
byteSize: number;
sha256: string;
originalFilename: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
updatedAt: Date;
contentPath: string;
}

View File

@@ -1,4 +1,5 @@
export interface SidebarBadges {
inbox: number;
approvals: number;
failedRuns: number;
}

View File

@@ -36,11 +36,13 @@ export {
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
type CreateIssue,
type UpdateIssue,
type CheckoutIssue,
type AddIssueComment,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
} from "./issue.js";
export {

View File

@@ -42,3 +42,9 @@ export const linkIssueApprovalSchema = z.object({
});
export type LinkIssueApproval = z.infer<typeof linkIssueApprovalSchema>;
export const createIssueAttachmentMetadataSchema = z.object({
issueCommentId: z.string().uuid().optional().nullable(),
});
export type CreateIssueAttachmentMetadata = z.infer<typeof createIssueAttachmentMetadataSchema>;