From 2931f600b4bcd1ad7324477c91cf00be55dd7dc7 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Mon, 26 Jan 2026 06:57:01 +0900 Subject: [PATCH] feat(artifacts): add S3-based artifact management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement artifact storage and distribution for θ-cycle outputs: - src/artifacts/types.ts: Artifact, ArtifactMetadata interfaces * ArtifactType enum (file, code, report, image, log, json) * SaveArtifactOptions, DownloadUrlOptions, ArtifactFilter - src/artifacts/manager.ts: S3 + DynamoDB client * save() / saveFile() - Upload to S3, store metadata in DynamoDB * getDownloadUrl() - Generate presigned URL (default: 1 hour) * get() - Retrieve artifact metadata * listBySession() / listByUser() - Query artifacts * deleteArtifact() / deleteBySession() - Cleanup * initializeTable() - DynamoDB table setup - src/artifacts/manager.test.ts: Unit tests (skeleton) - src/artifacts/index.ts: Module exports Features: - S3 storage with automatic expiration - Presigned URLs for secure downloads - DynamoDB metadata with TTL (default: 24 hours) - GSI for sessionId/userId/type queries - Session-based cleanup Co-Authored-By: Claude --- src/artifacts/index.ts | 8 + src/artifacts/manager.test.ts | 140 +++++++++++++ src/artifacts/manager.ts | 379 ++++++++++++++++++++++++++++++++++ src/artifacts/types.ts | 103 +++++++++ 4 files changed, 630 insertions(+) create mode 100644 src/artifacts/index.ts create mode 100644 src/artifacts/manager.test.ts create mode 100644 src/artifacts/manager.ts create mode 100644 src/artifacts/types.ts diff --git a/src/artifacts/index.ts b/src/artifacts/index.ts new file mode 100644 index 000000000..596c77848 --- /dev/null +++ b/src/artifacts/index.ts @@ -0,0 +1,8 @@ +/** + * 成果物管理モジュール + * + * @module artifacts + */ + +export * from "./types.js"; +export * from "./manager.js"; diff --git a/src/artifacts/manager.test.ts b/src/artifacts/manager.test.ts new file mode 100644 index 000000000..660d3cdd2 --- /dev/null +++ b/src/artifacts/manager.test.ts @@ -0,0 +1,140 @@ +/** + * 成果物マネージャーテスト + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + save, + saveFile, + getDownloadUrl, + get, + listBySession, + listByUser, + deleteArtifact, + deleteBySession, + initializeTable, +} from "./manager.js"; +import { ArtifactType } from "./types.js"; + +// AWS SDKモック +vi.mock("@aws-sdk/client-s3"); +vi.mock("@aws-sdk/client-dynamodb"); +vi.mock("@aws-sdk/lib-dynamodb"); +vi.mock("@aws-sdk/s3-request-presigner"); + +describe("artifact manager", () => { + const mockSessionId = "test-session-123"; + const mockUserId = "user-456"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("saveFile", () => { + it("should save text file and return download URL", async () => { + expect(true).toBe(true); + }); + + it("should save binary file and return download URL", async () => { + expect(true).toBe(true); + }); + + it("should save with custom TTL", async () => { + expect(true).toBe(true); + }); + + it("should save with tags and description", async () => { + expect(true).toBe(true); + }); + }); + + describe("getDownloadUrl", () => { + it("should return presigned URL for existing artifact", async () => { + expect(true).toBe(true); + }); + + it("should return null for non-existent artifact", async () => { + expect(true).toBe(true); + }); + + it("should return null for expired artifact", async () => { + expect(true).toBe(true); + }); + + it("should support custom expiration time", async () => { + expect(true).toBe(true); + }); + }); + + describe("get", () => { + it("should return artifact metadata", async () => { + expect(true).toBe(true); + }); + + it("should return null for non-existent artifact", async () => { + expect(true).toBe(true); + }); + }); + + describe("listBySession", () => { + it("should list artifacts for session", async () => { + expect(true).toBe(true); + }); + + it("should exclude expired artifacts", async () => { + expect(true).toBe(true); + }); + + it("should return empty array for session with no artifacts", async () => { + expect(true).toBe(true); + }); + }); + + describe("listByUser", () => { + it("should list artifacts for user", async () => { + expect(true).toBe(true); + }); + + it("should exclude expired artifacts", async () => { + expect(true).toBe(true); + }); + }); + + describe("deleteArtifact", () => { + it("should delete artifact from S3 and DynamoDB", async () => { + expect(true).toBe(true); + }); + + it("should be idempotent for non-existent artifact", async () => { + expect(true).toBe(true); + }); + }); + + describe("deleteBySession", () => { + it("should delete all artifacts for session", async () => { + expect(true).toBe(true); + }); + + it("should return count of deleted artifacts", async () => { + expect(true).toBe(true); + }); + + it("should handle empty session gracefully", async () => { + expect(true).toBe(true); + }); + }); + + describe("initializeTable", () => { + it("should create table if not exists", async () => { + expect(true).toBe(true); + }); + + it("should skip if table already exists", async () => { + expect(true).toBe(true); + }); + }); +}); diff --git a/src/artifacts/manager.ts b/src/artifacts/manager.ts new file mode 100644 index 000000000..919a783a1 --- /dev/null +++ b/src/artifacts/manager.ts @@ -0,0 +1,379 @@ +/** + * 成果物管理マネージャー + * + * S3 + DynamoDBでθサイクルの成果物を保存・配信する + */ + +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; +import { DynamoDBClient, DescribeTableCommand } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, + DeleteCommand, + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import type { + Artifact, + ArtifactMetadata, + ArtifactType, + SaveArtifactOptions, + DownloadUrlOptions, + ArtifactFilter, +} from "./types.js"; + +/** デフォルトTTL (24時間) */ +const DEFAULT_TTL_SECONDS = 24 * 60 * 60; + +/** デフォルトURL有効期限 (1時間) */ +const DEFAULT_URL_EXPIRES_SECONDS = 60 * 60; + +/** テーブル名 */ +const ARTIFACTS_TABLE = process.env.ARTIFACTS_TABLE_NAME || "clawdbot-artifacts"; + +/** S3バケット */ +const S3_BUCKET = process.env.ARTIFACTS_S3_BUCKET || "clawdbot-artifacts"; + +/** DynamoDBクライアント */ +const ddbClient = new DynamoDBClient({}); +const ddbDocClient = DynamoDBDocumentClient.from(ddbClient); + +/** S3クライアント */ +const s3Client = new S3Client({}); + +/** + * テーブル初期化(開発環境用) + */ +export async function initializeTable(): Promise { + try { + await ddbClient.send(new DescribeTableCommand({ TableName: ARTIFACTS_TABLE })); + console.log(`[ArtifactManager] Table ${ARTIFACTS_TABLE} exists`); + } catch { + console.log(`[ArtifactManager] Creating table ${ARTIFACTS_TABLE}`); + await ddbClient.send( + new CreateTableCommand({ + TableName: ARTIFACTS_TABLE, + AttributeDefinitions: [ + { AttributeName: "id", AttributeType: "S" }, + { AttributeName: "sessionId", AttributeType: "S" }, + { AttributeName: "userId", AttributeType: "S" }, + { AttributeName: "type", AttributeType: "S" }, + { AttributeName: "expiresAt", AttributeType: "N" }, + ], + KeySchema: [{ AttributeName: "id", KeyType: "HASH" }], + GlobalSecondaryIndexes: [ + { + IndexName: "SessionIndex", + KeySchema: [ + { AttributeName: "sessionId", KeyType: "HASH" }, + { AttributeName: "createdAt", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + { + IndexName: "UserIndex", + KeySchema: [ + { AttributeName: "userId", KeyType: "HASH" }, + { AttributeName: "createdAt", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + { + IndexName: "TypeIndex", + KeySchema: [ + { AttributeName: "type", KeyType: "HASH" }, + { AttributeName: "expiresAt", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + BillingMode: "PAY_PER_REQUEST", + TimeToLiveSpecification: { + AttributeName: "expiresAt", + Enabled: true, + }, + }), + ); + console.log(`[ArtifactManager] Table ${ARTIFACTS_TABLE} created`); + } +} + +/** + * S3にアップロード + */ +async function uploadToS3(key: string, data: Buffer | string, mimeType: string): Promise { + const body = typeof data === "string" ? Buffer.from(data) : data; + + await s3Client.send( + new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + Body: body, + ContentType: mimeType, + }), + ); +} + +/** + * S3から署名付きURLを生成 + */ +async function generatePresignedUrl( + key: string, + expiresIn: number = DEFAULT_URL_EXPIRES_SECONDS, +): Promise { + const command = new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + }); + + return getSignedUrl(s3Client, command, { expiresIn }); +} + +/** + * 成果物を保存 + * + * @param artifact - 成果物データ + * @param options - 保存オプション + * @returns ダウンロードURL + */ +export async function save(artifact: Artifact, options: SaveArtifactOptions = {}): Promise { + const { ttl = DEFAULT_TTL_SECONDS, tags, description, public: isPublic = false } = options; + + const id = `artifact-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const now = Date.now(); + const expiresAt = Math.floor(now / 1000) + ttl; + + // S3キー生成: {sessionId}/{type}/{filename} または {type}/{id}/{filename} + const sessionPrefix = artifact.metadata.sessionId ? `${artifact.metadata.sessionId}/` : ""; + const s3Key = `${sessionPrefix}${artifact.metadata.type}/${id}/${artifact.metadata.filename}`; + + // S3にアップロード + const data = artifact.buffer ?? artifact.text ?? ""; + await uploadToS3(s3Key, data, artifact.metadata.mimeType); + + // メタデータ構築 + const metadata: ArtifactMetadata = { + ...artifact.metadata, + id, + s3Key, + s3Bucket: S3_BUCKET, + createdAt: now, + expiresAt, + tags, + description, + }; + + // DynamoDBに保存 + await ddbDocClient.send( + new PutCommand({ + TableName: ARTIFACTS_TABLE, + Item: metadata, + }), + ); + + // ダウンロードURL生成 + if (isPublic) { + // 公開URL(S3バケットが公開設定されている場合) + return `https://${S3_BUCKET}.s3.amazonaws.com/${s3Key}`; + } + + return generatePresignedUrl(s3Key); +} + +/** + * 成果物を保存 (ヘルパー) + * + * @param sessionId - セッションID + * @param type - 種類 + * @param filename - ファイル名 + * @param data - データ + * @param mimeType - MIMEタイプ + * @param options - オプション + * @returns ダウンロードURL + */ +export async function saveFile( + sessionId: string, + type: ArtifactType, + filename: string, + data: Buffer | string, + mimeType: string, + options: SaveArtifactOptions = {}, +): Promise { + const size = typeof data === "string" ? data.length : data.length; + + const artifact: Artifact = { + metadata: { + sessionId, + type, + filename, + mimeType, + size, + createdAt: Date.now(), + expiresAt: 0, // 一時的な値、save()で上書き + } as ArtifactMetadata, + buffer: typeof data === "string" ? undefined : data, + text: typeof data === "string" ? data : undefined, + }; + + return save(artifact, options); +} + +/** + * ダウンロードURLを取得 + * + * @param artifactId - 成果物ID + * @param options - オプション + * @returns ダウンロードURL、存在しない場合はnull + */ +export async function getDownloadUrl( + artifactId: string, + options: DownloadUrlOptions = {}, +): Promise { + const { expires = DEFAULT_URL_EXPIRES_SECONDS } = options; + + const response = await ddbDocClient.send( + new GetCommand({ + TableName: ARTIFACTS_TABLE, + Key: { id: artifactId }, + }), + ); + + if (!response.Item) { + return null; + } + + const metadata = response.Item as unknown as ArtifactMetadata; + + // 期限切れチェック + if (Date.now() > metadata.expiresAt * 1000) { + return null; + } + + return generatePresignedUrl(metadata.s3Key, expires); +} + +/** + * 成果物を取得 + * + * @param artifactId - 成果物ID + * @returns メタデータ、存在しない場合はnull + */ +export async function get(artifactId: string): Promise { + const response = await ddbDocClient.send( + new GetCommand({ + TableName: ARTIFACTS_TABLE, + Key: { id: artifactId }, + }), + ); + + if (!response.Item) { + return null; + } + + const metadata = response.Item as unknown as ArtifactMetadata; + + // 期限切れチェック + if (Date.now() > metadata.expiresAt * 1000) { + return null; + } + + return metadata; +} + +/** + * セッションの成果物を一覧取得 + * + * @param sessionId - セッションID + * @returns 成果物メタデータ一覧 + */ +export async function listBySession(sessionId: string): Promise { + const response = await ddbDocClient.send( + new QueryCommand({ + TableName: ARTIFACTS_TABLE, + IndexName: "SessionIndex", + KeyConditionExpression: "sessionId = :sessionId", + ExpressionAttributeValues: { + ":sessionId": sessionId, + }, + }), + ); + + const items = (response.Items as unknown as ArtifactMetadata[]) || []; + + // 期限切れを除外 + return items.filter((item) => Date.now() <= item.expiresAt * 1000); +} + +/** + * ユーザーの成果物を一覧取得 + * + * @param userId - ユーザーID + * @returns 成果物メタデータ一覧 + */ +export async function listByUser(userId: string): Promise { + const response = await ddbDocClient.send( + new QueryCommand({ + TableName: ARTIFACTS_TABLE, + IndexName: "UserIndex", + KeyConditionExpression: "userId = :userId", + ExpressionAttributeValues: { + ":userId": userId, + }, + }), + ); + + const items = (response.Items as unknown as ArtifactMetadata[]) || []; + + // 期限切れを除外 + return items.filter((item) => Date.now() <= item.expiresAt * 1000); +} + +/** + * 成果物を削除 + * + * @param artifactId - 成果物ID + */ +export async function deleteArtifact(artifactId: string): Promise { + // メタデータ取得 + const metadata = await get(artifactId); + if (!metadata) { + return; + } + + // S3から削除 + await s3Client.send( + new DeleteObjectCommand({ + Bucket: metadata.s3Bucket, + Key: metadata.s3Key, + }), + ); + + // DynamoDBから削除 + await ddbDocClient.send( + new DeleteCommand({ + TableName: ARTIFACTS_TABLE, + Key: { id: artifactId }, + }), + ); +} + +/** + * セッションの成果物を全削除 + * + * @param sessionId - セッションID + */ +export async function deleteBySession(sessionId: string): Promise { + const artifacts = await listBySession(sessionId); + + for (const artifact of artifacts) { + await deleteArtifact(artifact.id); + } + + return artifacts.length; +} diff --git a/src/artifacts/types.ts b/src/artifacts/types.ts new file mode 100644 index 000000000..6916ecd35 --- /dev/null +++ b/src/artifacts/types.ts @@ -0,0 +1,103 @@ +/** + * 成果物管理の型定義 + * + * S3 + DynamoDBでθサイクルの成果物を保存・配信する + */ + +/** + * 成果物の種類 + */ +export enum ArtifactType { + /** ファイル (汎用) */ + FILE = "file", + /** コードスニペット */ + CODE = "code", + /** レポート */ + REPORT = "report", + /** 画像 */ + IMAGE = "image", + /** ログ */ + LOG = "log", + /** JSONデータ */ + JSON = "json", +} + +/** + * 成果物メタデータ + */ +export interface ArtifactMetadata { + /** 成果物ID */ + id: string; + /** セッションID (θサイクル) */ + sessionId?: string; + /** ユーザーID */ + userId?: string; + /** 種類 */ + type: ArtifactType; + /** ファイル名 */ + filename: string; + /** MIMEタイプ */ + mimeType: string; + /** サイズ (bytes) */ + size: number; + /** S3キー */ + s3Key: string; + /** S3バケット */ + s3Bucket: string; + /** 作成時刻 */ + createdAt: number; + /** 有効期限 (Unix timestamp) */ + expiresAt: number; + /** タグ */ + tags?: string[]; + /** 説明 */ + description?: string; +} + +/** + * 成果物 + */ +export interface Artifact { + /** メタデータ */ + metadata: ArtifactMetadata; + /** バイナリデータ (保存時のみ使用) */ + buffer?: Buffer; + /** テキストデータ (保存時のみ使用) */ + text?: string; +} + +/** + * 保存オプション + */ +export interface SaveArtifactOptions { + /** TTL (秒), デフォルト: 24時間 */ + ttl?: number; + /** タグ */ + tags?: string[]; + /** 説明 */ + description?: string; + /** 公開設定 */ + public?: boolean; +} + +/** + * ダウンロードURLオプション + */ +export interface DownloadUrlOptions { + /** 有効期限 (秒), デフォルト: 1時間 */ + expires?: number; +} + +/** + * 成果物検索フィルタ + */ +export interface ArtifactFilter { + /** セッションID */ + sessionId?: string; + /** ユーザーID */ + userId?: string; + /** 種類 */ + type?: ArtifactType; + /** タグ */ + tags?: string[]; +}