운영 플랫폼
paperclip은 여러 AI 에이전트의 업무 배정·실행·검토·비용을 한곳에서 관리하는 운영 플랫폼입니다. 브라우저에서 회사를 만들고 목표와 프로젝트를 연결한 뒤, 에이전트에게 업무를 배정합니다. 서버는 실행 가능한지 확인하고 적절한 실행 환경으로 보내며, 결과와 사용량을 기록합니다.
독자가 가장 먼저 기억할 문장은 이것입니다. 에이전트의 추론 능력보다, 여러 실행이 함께 일할 때 지켜야 할 규칙을 설계하는 저장소입니다. 모델이 답변을 만드는 내부 알고리즘보다 “이 업무를 지금 시작해도 되는가”, “중지 뒤에 누가 정리하는가”라는 질문이 코드의 중심에 있습니다.
유능한 직원 열 명을 한 방에 모았다고 회사가 저절로 굴러가지는 않습니다. 같은 일을 두 명이 잡거나, 결재 없이 비용을 쓰거나, 퇴근한 직원의 업무가 계속 진행 중으로 남을 수 있습니다.
paperclip은 직원에게 일을 잘하는 법을 전부 가르치기보다 누가 어떤 일을 맡았고, 어떤 권한과 예산으로 실행하며, 어디까지 끝냈는지를 관리합니다. 이 비유에서 모델은 두뇌, Claude Code·Codex 같은 도구는 작업 장비, paperclip은 운영 체계에 가깝습니다.
| 화면의 개념 | 코드에서 확인할 대상 | 배울 질문 |
|---|---|---|
| 회사와 에이전트 | companies·agents 테이블 | 다른 회사의 데이터에 접근하지 못하게 하려면? |
| 목표·프로젝트·업무 | issues의 goalId·projectId·parentId | 작은 작업에 상위 목적을 어떻게 연결할까? |
| 에이전트 실행 | heartbeat_runs와 heartbeat 서비스 | 배정한 업무와 실제 실행 기록은 왜 분리할까? |
| 비용과 승인 | budget·approval 관련 서비스와 스키마 | 금액과 권한이 바뀌면 진행 중 작업은 어떻게 할까? |
회사·업무·실행을 분리해 두면 하나의 업무를 여러 차례 재시도하더라도 업무 자체를 복제할 필요가 없습니다.
packages/db/src/schema/issues.ts와 heartbeat_runs.ts를 나란히 읽으면 이 차이가 드러납니다.
업무는 “끝내야 할 것”, 실행은 “그 일을 하려고 시도한 한 번”입니다.
AI 제품을 만들지 않더라도 배울 것이 있습니다. 예약 서비스의 중복 접수 방지, 쇼핑몰의 재고 차감, 파일 업로드의 무결성 검사, 관리자 화면의 실시간 갱신에도 같은 원리가 쓰입니다.
에이전트 하나를 쓸 때는 대화창만 있어도 충분할 수 있습니다. 하지만 조사 담당, 구현 담당, 검토 담당이 동시에 움직이면 담당자·진행 상태·승인·비용을 사람이 따로 맞춰야 합니다. README는 이 문제를 조직도, 목표, 예산, 거버넌스라는 회사 운영 개념으로 설명합니다.
이 제품 구성이 관심을 모았을 가능성은 높습니다. 다만 트렌딩 순위만으로 유입의 원인을 입증할 수는 없습니다. 2026-10-23 GitHub 기준 Monthly #1, 스타 106,337개, 포크 17,680개는 관심의 규모를 보여 줍니다. 아래의 주목 요인은 README의 제품 방향과 구현된 기능을 바탕으로 한 해석입니다.
| 관심을 끌 만한 지점 | 소스에서 연결되는 근거 | 학습 가치 |
|---|---|---|
| 기존 에이전트를 가져와 연결 | server/src/adapters/registry.ts에 Claude·Codex·Cursor 등 등록 | 외부 도구마다 다른 실행 방식을 계약으로 감싸기 |
| 업무의 중복 실행 방지 | issueService().checkout()의 조건부 갱신 | 동시 요청에 안전한 상태 변경 |
| 비용을 운영 규칙에 포함 | server/src/services/budgets.ts의 정책·예약·중지 처리 | 금액 표시와 지출 통제를 분리해 이해하기 |
| 자체 호스팅과 웹 관리 화면 | Express 서버·React UI·내장 PostgreSQL 초기화 | 개발부터 배포까지 이어지는 제품 구성 |
이 저장소의 강점은 서로 다른 에이전트를 같은 업무·권한·비용 체계 안에서 운영하게 해 준다는 점입니다. 모델 성능이 같더라도 운영 규칙을 공유하면 사람이 결과를 추적하고 개입하기 쉬워집니다.
| 비교 대상 | 주된 출발점 | paperclip이 유리한 상황 |
|---|---|---|
| Claude Code·Codex 같은 코딩 에이전트 | 하나의 작업 환경에서 코딩과 도구 사용 | 여러 에이전트의 담당 업무와 이력을 함께 관리할 때 |
| LangGraph | 상태·노드·전이로 장기 실행 에이전트 흐름을 구현 | 회사·업무·예산을 다루는 관리 애플리케이션부터 필요할 때 |
| CrewAI의 공개 프레임워크 | 에이전트 팀과 Flow를 코드로 구성 | 서로 다른 실행 도구를 공통 운영 화면에 연결할 때 |
| 일반 업무 보드 | 할 일·담당자·진행 상태를 사람이 관리 | 업무 배정이 실제 에이전트 실행·세션·비용과 이어져야 할 때 |
비교 기준은 각 도구의 중심 추상화입니다. LangGraph 공식 개요 문서는 상태를 갖는 그래프와 영속 실행을, CrewAI 공식 소개는 에이전트 팀과 Flow를 설명합니다. 이들도 영속성·감시·사람의 개입을 지원하는 생태계가 있으므로, 특정 기능의 독점 여부나 성능 순위를 뜻하는 표는 아닙니다.
LangGraph나 CrewAI로 작업자의 일하는 절차를 설계할 수 있다면, paperclip에서는 그 작업자가 속한 조직의 업무대장과 결재 흐름을 관리합니다. 실제 선택에서는 둘이 연결될 수도 있습니다. 비교할 것은 이름보다 내가 직접 만들어야 하는 관리 기능의 범위입니다.
공개 프레임워크 하나를 감싼 작은 예제가 아닙니다. 웹 화면, 인증, 데이터베이스, 원격 실행, 플러그인, 복구 로직까지 있습니다. TypeScript·TSX만 합쳐 약 260.8만 줄이며 테스트·스토리·생성 파일도 포함한 물리적 줄 수입니다. 초보자는 전체를 정독하기보다 4절의 한 가지 요청 경로부터 따라가는 편이 좋습니다.
아래 버전은 커밋 412914c 기준입니다.
package.json의 ^는 허용 범위이고, pnpm-lock.yaml의 값은 그 시점에 기록된 해석 결과입니다.
의존성 선언의 버전 범위와 실제 잠금 버전을 구분해야 같은 환경을 설명할 수 있습니다.
workspace:*는 인터넷의 임의 버전 대신 같은 워크스페이스의 패키지를 참조한다는 표시입니다.| 기술 | 실제 선언 / 잠금 값 | 담당 역할과 근거 파일 |
|---|---|---|
| Node.js | >=24.11.0 | 서버·CLI 런타임. 루트 및 각 패키지의 engines |
| Express | ^5.1.0 / 5.2.1 | HTTP API·미들웨어. server/package.json |
| Drizzle ORM | ^0.45.2 / 0.45.2 | TypeScript 스키마·SQL 쿼리. packages/db/package.json |
| postgres | ^3.4.9 / 3.4.9 | PostgreSQL 접속 드라이버. packages/db/src/client.ts |
| embedded-postgres | ^18.1.0-beta.16 / 동일 버전 | 로컬 PostgreSQL 프로세스 관리. 서버와 DB 패키지 |
| Zod | ^4.4.3 / 4.4.3 | 입력값을 실행 시 검증. packages/shared/package.json |
| Better Auth | 1.7.2 | 인증 세션 기반. server/package.json |
| ws / Pino | ^8.22.0 / ^10.0.0 | WebSocket 통신 / 구조화 로그. 서버 의존성 선언 |
| AI SDK / MCP SDK | ai: 7.0.130 / @modelcontextprotocol/sdk: ^1.31.0 | 모델·도구 연결에 쓰는 서버 의존성 |
packages/db/src/client.ts는 drizzle-orm/postgres-js와 postgres를 가져옵니다.
server/src/index.ts는 연결 문자열이 있으면 외부 PostgreSQL을 쓰고, 없으면 embedded-postgres를 동적으로 불러옵니다.
내장 모드도 실제 PostgreSQL을 관리하는 구성이지 브라우저 안에서 동작하는 DB라는 뜻이 아닙니다.
단, authenticated·public 배포는 외부 PostgreSQL 연결 문자열이 필수입니다.
루트 AGENTS.md의 개발 설정에는 “PGlite”와 data/pglite가 남아 있습니다.
반면 README의 빠른 시작은 내장 PostgreSQL을 설명하며, 의존성과 server/src/index.ts도 이를 뒷받침합니다.
DB의 종류와 데이터 위치를 판단할 때는 현재 초기화 코드와 설정 경로를 함께 확인해야 합니다.
| 기술 | 버전 선언 | 어디에 쓰이는가 |
|---|---|---|
| React / React DOM | ^19.2.8 / 각각 잠금 19.2.8 | 관리 화면과 상태에 따른 렌더링 |
| React Router | react-router-dom: ^7.18.4 | 회사·업무·에이전트 화면의 이동 |
| TanStack Query | ^5.102.8 | 서버 데이터의 캐시·재조회·부분 갱신 |
| Vite | ^8.2.2 / 잠금 8.2.2 | 개발 서버·번들. ui/vite.config.ts |
| Tailwind CSS | ^4.3.3 | 스타일과 Vite 플러그인. ui/package.json |
| Base UI / Radix UI | ^1.7.0 / radix-ui: ^1.6.7 | 접근성을 고려한 UI 구성 요소 |
| Lexical / Yjs | 0.48.0 / 13.6.32 | 편집기·공유 문서 관련 기반 |
| Storybook | 10.6.0 | 화면 상태별 예제. ui/storybook/ |
프론트 버전의 근거는 ui/package.json입니다.
같은 저장소의 Runner 개발 화면은 별도 package.json에서 다른 Vite 범위를 선언하므로,
특정 버전을 “저장소 전체가 공통으로 사용한다”고 일반화하면 안 됩니다.
| 계층 | 설정값 | 읽을 포인트 |
|---|---|---|
| 패키지 관리 | pnpm@9.15.4 | 루트 packageManager로 도구 버전 고정 |
| 정적 타입 검사 | typescript: ^7.0.2, 잠금 7.0.2 | 루트·서버·UI 패키지의 타입 계약 확인 |
| 테스트 | vitest: ^5.0.3, 루트 @playwright/test: ^1.62.1 | 함수·서비스 검증과 브라우저 검증을 분리 |
| Rust 실행기 | rust-toolchain.toml: 1.97.1, edition 2021 | packages/paperclip-runner/runner/Cargo.toml |
| Rust 의존성 | serde: 1.0, tokio: 1.50, tungstenite: 0.28 | Cargo 워크스페이스 선언 범위. 직렬화·비동기·WebSocket |
| 컨테이너 | node:24-trixie-slim, PORT=3100 | 루트 Dockerfile의 다단계 빌드 |
| CI | Node 24, pnpm 9.15.4 | .github/workflows/pr-trusted.yml의 정책·타입·테스트·빌드 작업 |
pnpm-workspace.yaml은 서버·UI·CLI와 여러 패키지를 묶으면서
packages/plugins/sandbox-providers/**는 제외합니다. 해당 플러그인의 외부 의존성을 독립적으로 설치하려는 경계입니다.
또한 postgres·embedded-postgres 등에 패치를 적용합니다. 버전 숫자만 같아도 패치가 다르면 코드가 같지 않을 수 있습니다.
CI는 pr.yml에서 재사용 워크플로를 호출합니다.
pr-trusted.yml의 정책 작업은 수동 잠금 파일 변경을 제한하고 의존성 해석을 검사합니다.
설치 액션은 고정 잠금 설치가 실패하면 잠금을 다시 해석하는 경로도 갖습니다.
따라서 잠금 파일은 중요한 근거이지만, 모든 빌드가 아무 조건 없이 동일한 의존성으로 끝난다는 증거는 아닙니다.
이 그림의 핵심 경계는 업무를 관리하는 서버와 실제로 도구를 실행하는 환경 사이입니다. 같은 컴퓨터에 둘 다 있을 수도 있고 원격으로 떨어져 있을 수도 있습니다. 경계가 나뉘면 네트워크 단절을 프로세스 종료로 오해하지 않는 설계가 필요해집니다.
서버의 시작점은 server/src/index.ts입니다. 설정과 DB를 준비하고
server/src/app.ts의 createApp()으로 HTTP 애플리케이션을 구성합니다.
UI가 같은 주소에서 보이더라도 API·업무 서비스·실행 환경은 서로 다른 책임을 갖습니다.
POST /api/issues/:id/checkout은 에이전트가 업무를 가져가는 입구입니다.
server/src/routes/issues.ts는 요청 검증, 접근 가능한 업무 조회, 프로젝트 중지 여부,
에이전트 본인의 ID인지 등을 확인한 뒤 svc.checkout()을 호출합니다.
// packages/shared/src/validators/issue.ts의 실제 입력 계약
export const checkoutIssueSchema = z.object({
agentId: z.string().guid(),
expectedStatuses: z.array(z.enum(ISSUE_STATUSES)).nonempty(),
});
서비스 구현은 server/src/services/issues.ts의 issueService(db)가 반환하는
checkout 메서드입니다. 회사 내 배정 가능 여부, 업무 트리의 중지 조건, 선행 업무의 해결 여부를 검사합니다.
그다음 예상 상태·담당자·실행 잠금 조건이 모두 맞는 행만 UPDATE합니다.
-- 실제 Drizzle 조건을 단순화해 풀어 쓴 학습용 SQL
UPDATE issues
SET assignee_agent_id = :agent_id,
checkout_run_id = :run_id,
execution_run_id = :run_id,
status = 'in_progress'
WHERE id = :issue_id
AND status IN (:expected_statuses)
AND (assignee_agent_id IS NULL OR :same_run_assignee)
AND (execution_run_id IS NULL OR execution_run_id = :run_id)
RETURNING *;
“먼저 조회했을 때 비어 있었다”는 사실만 믿으면 두 요청이 모두 성공할 수 있습니다. 이 코드는 갱신하는 순간에도 조건을 검사합니다. 다른 실행이 먼저 소유권을 잡으면 후속 갱신의 조건이 맞지 않습니다. 갱신 결과가 없을 때는 현재 상태를 다시 읽어 같은 실행의 재요청인지, 복구 가능한 오래된 잠금인지 등을 판별합니다.
복구·재요청 조건까지 해결되지 않으면 conflict("Issue checkout conflict", ...)로 끝납니다.
또한 assertCheckoutOwner()는 이후 변경에서도 소유권을 검사합니다.
잠금을 처음 잡았다는 이유로 모든 후속 행동을 영구 허용하지 않는 것이 중요합니다.
호텔 예약 화면에서 빈 객실을 본 것과 예약 확정은 다릅니다.
결제 직전에 객실이 아직 비어 있는지 확인하며 예약 상태를 바꿔야 합니다.
expectedStatuses는 “내가 봤던 상태일 때만 처리해 달라”는 조건표입니다.
server/src/services/heartbeat.ts의 heartbeatService()는 실행 생명주기를 조립합니다.
createHeartbeatQueue()가 대기열을, executeRunAttempt()가 실행 시도를,
createHeartbeatRunCompletion()의 completeRun()이 완료 처리를 맡습니다.
| 단계 | 주요 파일 | 해당 단계의 질문 |
|---|---|---|
| 대기열과 시작 | server/src/services/heartbeat/queue.ts | 이미 진행 중인 작업과 겹치는가? 새 실행이 필요한가? |
| 실행 문맥·세션 준비 | run-preparation.ts·session-preparation.ts | 어떤 업무·도구·이전 대화를 전달할까? |
| 환경·런타임 선택 | runtime-selection.ts·workspace-preparation.ts | 어디서 어떤 방식으로 실행할까? |
| 실행 | runtime-execution.ts | 준비된 실행을 어느 백엔드에 넘길까? |
| 정리와 복구 | run-completion.ts·recovery.ts | 결과·상태·리소스를 어떻게 정리하고 이어 갈까? |
executeHeartbeatRuntime()에는 native 실행과 legacy adapter 실행 경로가 함께 있습니다.
native 경로는 executePaperclipNativeSession()에 위임하고,
legacy 경로는 adapter.execute(...)를 호출합니다. 현재 코드를 옛날 어댑터 설명 하나로만 이해하면 Rust Runner 경로를 놓칩니다.
// packages/adapter-utils/src/types.ts의 계약 일부
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
// 세션·모델·스킬 등 선택적 기능은 별도 필드로 선언
}
이것은 전략 패턴을 읽는 좋은 예입니다.
호출자는 공통 계약을 사용하고, 제공자별 명령과 결과 해석은 구현체가 맡습니다.
AdapterExecutionResult는 종료 코드뿐 아니라 usage, costStatus, sessionParams 등도 표현합니다.
server/src/adapters/registry.ts는 구현을 등록하고 선택합니다.
getServerAdapter()는 찾지 못할 때 process 어댑터로 대체하지만,
requireServerAdapter()는 알 수 없는 유형을 오류로 처리합니다.
같은 레지스트리라도 호출자가 요구하는 실패 정책에 따라 함수가 다릅니다.
실행 결과를 받았다는 사실과 프로세스 종료·비용 정산·파일 정리가 끝났다는 사실은 서로 다릅니다.
heartbeat_runs에는 실행 상태 외에도 정산 대기 여부, 세션 ID, 로그 참조, 재시도 연결,
컨트롤러 소유권 관련 필드가 있습니다. 이런 구분이 있어야 재시작 뒤에 남은 의무를 판단할 수 있습니다.
Rust 구현은 packages/paperclip-runner/runner/crates/runner-core/src/durable/에 있습니다.
그 안의 state.rs에서 Command는 command_id와 controller_seq를 갖고,
validate()는 스키마 버전·식별자·순번·페이로드 형태를 검사합니다.
// durable/state.rs의 명령 필드 일부
pub struct Command {
pub schema: String,
pub command_id: String,
pub controller_seq: u64,
// 명령 유형·시각·사전 조건·페이로드가 이어짐
}
같은 파일의 save()는 상태를 직렬화하고 임시 파일에 쓴 다음
sync_all(), fs::rename()을 거쳐 상태 파일을 교체합니다.
Unix에서는 상위 디렉토리도 동기화합니다. 파일에 JSON을 썼다는 사실과 장애 후에도 복구할 수 있게 저장했다는 사실의 차이를 보여 줍니다.
서버의 예약 작업도 메모리 타이머 하나에 모든 책임을 맡기지 않습니다.
createWorkScheduler()는 이름별 기한을 Map에 넣고 가장 가까운 기한에 타이머를 맞춥니다.
createDeliveryWorkCoordinator()는 중복 실행을 조정하고 실패 후 재시도와 DB 작업 신호를 연결합니다.
다만 이 스케줄러 자체는 프로세스 메모리이므로, 재시작 복구에는 영속 데이터에서 할 일을 다시 구성하는 경로가 필요합니다.
server/src/services/budgets.ts의 budgetPoliciesForRun()은 회사·에이전트·프로젝트에 해당하는 정책을 고릅니다.
recheckAdmittedRunBudgetReadOnly()는 실행이 보유한 예약과 현재 지출을 다시 검사합니다.
미정산 실행이나 가격을 확정할 수 없는 사용량도 정책에 따라 실행을 막을 수 있습니다.
이 재검사는 이미 승인된 실행의 인계 단계에 붙는 읽기 전용 보완 검사입니다. 예산 초과 시 중지 전달과 정산은 다른 경로의 책임입니다. 따라서 “예산 상한이 있으니 외부 제공자의 청구액도 언제나 한 푼도 넘지 않는다”고 해석해서는 안 됩니다.
server/src/services/live-events.ts의 publishLiveEvent()는 회사별 EventEmitter 알림을 발행합니다.
server/src/realtime/live-events-ws.ts가 /api/companies/:id/events/ws 연결을 처리하며 접근 권한을 검사합니다.
회사 ID가 URL에 있다는 사실만으로 구독 권한이 생기지는 않습니다.
클라이언트의 ui/src/context/LiveUpdatesProvider.tsx는 소켓을 공유합니다.
applyRunLifecycleToCompanyLiveRuns()는 기존 실행의 상태를 캐시에 반영하고,
종료된 실행을 활성 목록에서 제거합니다. 반면 필요한 데이터가 부족한 경우에는 다시 조회합니다.
| 상황 | 현재 코드의 대응 | 이유 |
|---|---|---|
| 기존 실행의 상태 변경 | setQueryData()로 캐시를 부분 갱신 | 모든 이벤트마다 전체 데이터를 받지 않기 위해 |
| 첫 연결·재연결 | 활성 쿼리를 무효화해 재조회 | 연결 전에 놓친 이벤트가 있을 수 있기 때문에 |
| 연결 실패 | 1·2·4·8초 뒤 재시도, 이후 최대 15초 간격 | 과도한 재접속을 줄이기 위해 |
| 실시간 연결 불가 | 15초 간격으로 활성 쿼리 갱신 | 화면이 영구히 오래된 상태로 남지 않게 하기 위해 |
이 UI 이벤트 경로는 프로세스 내부 알림이며 놓친 이벤트를 재생하지 않습니다. 클라이언트도 재연결 시 이를 전제로 데이터를 다시 조회합니다. Rust Runner의 영속 상태·명령 처리와는 목적이 다릅니다. 이 알림 코드만 보고 다중 서버 간 방송이나 모든 이벤트의 보존을 가정할 수 없습니다.
packages/db/src/schema/issues.ts: 업무가 어떤 데이터를 갖는지 먼저 봅니다.packages/shared/src/validators/issue.ts: 외부 요청에서 허용하는 형태를 봅니다.server/src/routes/issues.ts: /issues/:id/checkout을 찾아 입구를 봅니다.server/src/services/issues.ts: checkout: async를 찾아 실제 변경 조건을 봅니다.ui/src/context/LiveUpdatesProvider.tsx: 변경이 화면에 어떻게 반영되는지 봅니다.서비스 파일은 매우 크므로 첫 줄부터 읽으면 맥락을 잃기 쉽습니다. 함수 이름으로 이동하고, 인자와 반환값을 기록한 뒤, 필요한 호출만 확장하세요. 폴더 전체를 이해하는 것보다 “업무 하나의 소유권이 바뀌는 경로”를 설명할 수 있는 것이 먼저입니다.
| 언어·확장자 | 파일 수 | 물리적 줄 수 |
|---|---|---|
TypeScript .ts | 6,430 | 2,038,371 |
TSX .tsx | 1,886 | 569,131 |
Rust .rs | 70 | 80,775 |
JavaScript .mjs/.cjs/.js | 515 | 86,776 |
SQL .sql | 382 | 15,712 |
CSS .css | 25 | 12,942 |
Shell .sh | 58 | 12,328 |
Python .py / Go .go | 26 / 3 | 6,411 / 98 |
| 위 확장자 합계 | 9,395 | 2,822,544 |
커밋 412914c의 Git 추적 파일에서 위 확장자의 줄바꿈을 합산한 값입니다.
빈 줄·주석·테스트·스토리·생성 코드가 포함되며, Markdown 등 문서 형식·JSON·잠금 파일은 제외했습니다.
의존성·빌드·캐시 디렉토리와 심볼릭 링크도 제외했습니다. 순수 구현량이나 기능 품질 점수로 읽으면 안 됩니다.
같은 확장자 기준으로 server/ 약 122만 줄, ui/ 약 67만 줄,
packages/ 약 71만 줄, cli/ 약 4.5만 줄입니다.
packages/db/src/migrations/에는 SQL 파일이 373개 있습니다.
이 숫자는 스키마 변화의 규모를 가늠하게 해 주지만, 이 파일 집계만으로 전체 개발 기간의 커밋 수나 기여 비중까지 알 수는 없습니다.
TypeScript 타입은 개발 중 실수를 줄여 주지만 인터넷에서 들어온 JSON을 자동으로 검사하지 않습니다.
server/src/middleware/validate.ts의 validate(schema)는
schema.parse(req.body)를 호출해 실제 값을 검사하고 다음 미들웨어로 넘깁니다.
// packages/shared/src/validators/issue.ts의 실제 타입 추론
export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
// 읽기 실험: 아래 두 JSON을 스키마와 대조해 보세요.
{ "agentId": "올바른 UUID가 아님", "expectedStatuses": ["todo"] }
{ "agentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "expectedStatuses": [] }
배울 것: 하나의 스키마에서 검증과 타입을 함께 정의하는 방식입니다. 작은 실습: 위 두 요청이 각각 어느 조건에서 실패하는지 적고, “올바른 UUID와 비어 있지 않은 상태 배열”로 고쳐 보세요. 그다음 입력 형태가 맞아도 권한과 업무 상태 검사는 따로 필요하다는 점까지 설명해 봅니다.
pgTable()은 테이블을 TypeScript로 표현하고, and()·eq()·isNull()은 SQL 조건을 구성합니다.
ORM을 사용해도 조건부 UPDATE, 외래 키, 인덱스, 트랜잭션의 의미를 알아야 합니다.
issues.ts에는 companyId와 상태를 묶은 인덱스도 있습니다.
배울 것: 데이터가 같은 회사에 속한다는 관계와, 요청자가 그 회사에 접근할 수 있다는 권한은 별도라는 점입니다.
작은 실습: 업무 목록을 “회사 + 담당자 + 상태”로 찾는다고 가정하고,
스키마의 assigneeStatusIdx가 어떤 조회를 돕는지 설명해 보세요.
창이 열렸는지 같은 UI 상태와, 현재 업무가 진행 중인지 같은 서버 상태는 다릅니다. 후자는 다른 에이전트가 바꿀 수 있습니다. 이때 화면 안의 변수 하나만 바꾸면 다른 목록이나 상세 화면이 오래된 값을 갖게 됩니다.
배울 것: LiveUpdatesProvider.tsx에서 setQueryData()와
invalidateQueries()의 역할을 비교하세요. 전자는 아는 값을 즉시 반영하고, 후자는 필요한 데이터를 서버에서 다시 확인하도록 합니다.
작은 실습: 종료 이벤트를 두 번 받았을 때 활성 실행 목록에서 같은 실행을 제거해도 괜찮은 이유를 써 봅니다.
packages/adapter-utils/src/types.ts의 AdapterExecutionResult에는
usageBasis가 per_run인지 session_cumulative인지 나타내는 필드가 있습니다.
세션을 재사용했다고 사용량까지 누적 값이라고 추정해서는 안 된다는 주석도 있습니다.
자동차 계기판의 14,000km와 이번 여행의 4,000km는 다릅니다. 이전 계기판이 10,000km였다면 누적 값끼리 빼야 여행 거리가 나옵니다. 학습 예시로 토큰 누적값이 10,000에서 14,000으로 늘었다면 이번 사용량은 4,000입니다. 보고 방식이 다르면 같은 숫자의 뜻도 달라집니다.
배울 것: 외부 도구의 결과를 내부 계약으로 바꿀 때 정보의 기준도 함께 전달해야 합니다.
작은 실습: costStatus의 reported·estimated·unpriced를
한국어로 설명해 보세요. 가격을 알 수 없는 결과를 비용 0으로 취급하면 어떤 문제가 생길지도 적습니다.
packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs에서 Command::validate()와 save()를 읽습니다.
Rust 문법을 모두 몰라도 Result가 실패를 표현하고,
?가 오류를 호출자에게 전달한다는 정도부터 시작할 수 있습니다.
배울 것: 상태 저장에 크기 제한·임시 파일·동기화·교체가 필요한 이유입니다. 작은 실습: “임시 파일을 쓰다가 종료”, “교체 직전 종료”, “교체 뒤 종료”를 나누고, 각 경우에 이전 파일과 새 파일 중 무엇을 읽을 수 있어야 하는지 표로 정리하세요. 실제 파일시스템의 보장은 별도 검증 대상입니다.
server/src/services/native-runtime/native-runner-file-handoff.ts의
readVerifiedWorkspaceFile()는 원격 실행이면 readRemoteTaskFile 또는
readRemoteWorkspaceFile에 읽기를 위임합니다. 받은 바이트 수와 SHA-256도 확인합니다.
로컬 경로에서는 작업 경계 밖 이동과 심볼릭 링크를 검사합니다.
배울 것: 원격 기기의 파일을 서버의 같은 문자열 경로에서 찾으면 안 된다는 점입니다.
작은 실습: native-runner-file-handoff.test.ts에서 원격 읽기와 해시 불일치 사례를 찾아,
경로 검사와 내용 검사가 각각 방어하는 실패를 구분합니다.
server/src/__tests__/issues-checkout-wakeup.test.ts는 활성 실행 중인 에이전트의 자기 checkout이
추가 깨우기를 만들지 않는 규칙을 검사합니다. 이 테스트 하나가 DB의 모든 동시성까지 증명하지는 않습니다.
반면 budgets-service.test.ts는 내장 PostgreSQL을 이용하는 테스트 경로를 갖고 환경이 지원되지 않으면 건너뜁니다.
테스트를 읽을 때는 통과 여부와 함께 실제로 어떤 경계까지 실행했는지를 확인해야 합니다. 작은 실습: 함수 테스트·실제 DB 테스트·브라우저 테스트가 각각 발견할 수 있는 버그를 하나씩 적어 보세요. CI 파일을 읽으며 해당 검사가 어느 작업에서 실행되는지 연결하면 검증의 전체 지도가 생깁니다.
| 항목 | 확인할 조건 | 근거와 적용 범위 |
|---|---|---|
| Node.js | 24.11.0 이상 | 루트 package.json의 실행 환경 선언 |
| pnpm | 9.15.4 | packageManager 고정값. README는 9.15 이상으로 안내 |
| Rust | 1.97.1 툴체인 | 네이티브 실행기를 소스 빌드하는 경우. 패키지의 rust-toolchain.toml |
| 네이티브 빌드 도구 | C 컴파일러·개발 라이브러리 등 | Docker의 Rust 단계는 gcc·libc6-dev·pkg-config 설치 |
| 데이터베이스 | 내장 PostgreSQL 또는 외부 PostgreSQL | DATABASE_URL 또는 설정 파일의 연결 문자열에 따른 초기화 분기 |
| 기본 포트 | 웹/API 3100, 내장 DB 54329 | server/src/config.ts. 설정·사용 중 포트에 따라 달라질 수 있음 |
| Linux 배포 바이너리 | x64·ARM64, glibc 2.39 이상 | Runner README의 배포 기준. 모든 배포판 지원을 뜻하지 않음 |
| macOS 배포 바이너리 | ARM64·x64 | Runner README 기준. 아키텍처별 검증 범위에 차이가 있음 |
| Windows·Alpine/musl | native 실행기 검증은 미완료로 문서화 | 웹 UI 접속 가능 여부와 실행 호스트의 지원 여부는 별개 |
| GPU | 외부 모델 API를 쓰는 제어 서버 구성에서는 필수 아님 | 로컬 모델 추론을 추가하면 그 모델의 요구사항이 별도로 필요 |
| CPU·메모리·저장 공간 | README에 최소 수치 미기재 | 동시 실행 수·빌드·로그·작업 사본 크기에 크게 좌우됨 |
소스 전체 빌드까지 공부하려는 개인 환경은 4코어·RAM 16GB·여유 디스크 30GB 이상을 출발점으로 생각할 수 있습니다. 이것은 필자의 학습 환경 권장 추정이며 공식 최소 사양이나 측정 결과가 아닙니다. Rust 빌드 캐시와 에이전트별 저장소 사본이 쌓이면 이보다 더 필요할 수 있습니다.
필요한 자원은 에이전트 수보다 동시에 살아 있는 실행·프로세스·작업 사본의 수에 더 직접적으로 영향을 받습니다. 서버 메모리만 보지 말고 하위 프로세스, DB, 작업 디렉토리, 로그 저장량까지 함께 살펴야 합니다.
packages/shared/src/home-paths.ts는 기본 홈을 ~/.paperclip,
인스턴스 ID를 default로 정합니다. 기본 인스턴스 루트는
~/.paperclip/instances/default이며 PAPERCLIP_HOME과 PAPERCLIP_INSTANCE_ID로 바꿀 수 있습니다.
| 인스턴스 루트 아래 경로 | 용도 |
|---|---|
config.json / db/ | 설정 / 내장 PostgreSQL 데이터 |
logs/ | 로그 |
data/storage/ / data/backups/ | 기본 파일 저장소 / 백업 디렉토리 |
secrets/master.key | 기본 비밀정보 키 경로. DB만 보관하는 것과 별도로 복구 계획이 필요 |
외부 DB·객체 저장소·원격 작업 환경을 사용하면 데이터가 여러 곳에 나뉩니다. 따라서 기본 폴더 하나의 복사만으로 모든 구성의 완전한 복구가 된다고 가정하면 안 됩니다. 코드의 경로 기본값과 실제 배포 설정을 함께 보아야 합니다.
서버 설정의 기본 호스트는 127.0.0.1입니다.
Docker 이미지는 HOST=0.0.0.0과 PAPERCLIP_DEPLOYMENT_MODE=authenticated,
PAPERCLIP_DEPLOYMENT_EXPOSURE=private를 선언합니다. 로컬 실행과 컨테이너의 기본값을 혼동하지 마세요.
선택한 모델·연결 도구·원격 실행 환경은 외부 서비스와 통신할 수 있습니다.
README와 서버 설정상 제품 사용 텔레메트리도 기본 활성화입니다. README는
PAPERCLIP_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1 등의 비활성화 방법을 안내합니다.
이 경로는 운영자가 OTLP 주소를 지정해야 활성화되는 OpenTelemetry 추적과 구분해야 합니다. “데이터를 직접 보관한다”는 말과 “어떤 외부 요청도 없다”는 말은 같은 뜻이 아닙니다.
README의 소스 개발 시작 명령은 다음과 같습니다. 전체 빌드에는 Rust 등 추가 요구사항이 연결되며, 실제 에이전트 실행에는 해당 제공자의 인증과 실행 환경 준비가 필요합니다.
pnpm install
pnpm dev
# README 기준 UI와 API: http://localhost:3100
# 로컬 구성에서 외부 DB 연결 문자열이 없으면 내장 PostgreSQL을 준비
앞의 두 과제는 애플리케이션을 띄우지 않고 시작할 수 있습니다. 실행 실험은 별도의 학습용 환경에서 가짜 에이전트와 연습용 데이터로 진행하세요. 좋은 실습의 완료 기준은 코드를 바꾼 양보다 실패 상황의 결과를 설명하고 확인할 수 있는가에 있습니다.
입력 스키마 → 라우트 → 서비스 → DB 열을 연결합니다. 각 단계 옆에 “잘못된 입력”, “다른 에이전트 ID”, “예상과 다른 상태”, “다른 실행의 잠금”을 적으세요.
rg -n 'checkoutIssueSchema' packages/shared/src/validators/issue.ts
rg -n '/issues/:id/checkout' server/src/routes/issues.ts
rg -n 'checkout: async|Issue checkout conflict' server/src/services/issues.ts
완료 기준: 입력 형식 오류, 권한 거부, 상태 충돌을 서로 다른 단계로 설명한 한 장짜리 지도를 만듭니다. 정상 요청의 JSON 예시도 하나 작성합니다.
issues.ts와 heartbeat_runs.ts에서 업무 식별자, 담당자, 상태, 실행 ID,
세션 ID, 비용 정산 필드를 찾아 표로 옮깁니다. “업무 하나를 두 번 시도했다”는 예시 데이터를 종이에 만드세요.
완료 기준: 업무 1개와 실행 2개가 연결된 관계도를 그립니다. 첫 실행이 실패해도 업무 제목을 복제할 필요가 없는 이유, 실행 종료와 정산 완료를 나눠야 하는 이유를 각각 두 문장으로 씁니다.
격리된 연습용 PostgreSQL에 ID·상태·실행 ID만 가진 작은 테이블과 행 하나를 만듭니다. 두 연결에서 같은 행을 서로 다른 실행 ID로 가져가 보세요. 먼저 조회 뒤 무조건 갱신하는 방식과, 갱신 조건에 “실행 ID가 비어 있음”을 넣은 방식을 비교합니다.
완료 기준: 두 구현의 실행 순서표와 결과를 남깁니다.
이 작은 실험이 전체 checkout()의 권한·복구 정책까지 증명하지는 않는다는 범위도 명시합니다.
독립된 TypeScript 연습 프로젝트에서 성공·실패·타임아웃을 반환하는 가짜 실행기를 만듭니다. 아래는 실제 어댑터보다 작은 교육용 계약입니다. 특정 모델의 SDK를 조율 코드에 넣지 않는 데 초점을 맞춥니다.
type RunResult = { exitCode: number | null; timedOut: boolean };
type Executor = (task: string) => Promise<RunResult>;
const fakeExecutor: Executor = async (_task) => ({
exitCode: 0,
timedOut: false,
});
완료 기준: 실행기를 교체해도 조율 함수의 분기 구조가 제공자 이름에 의존하지 않게 만듭니다. 성공·오류·시간 초과 세 경우와, 중지 요청 뒤 늦게 도착한 결과를 어떻게 취급할지 테스트 또는 상태표로 제시합니다.
상황은 이렇습니다. 실행 결과는 접수됐지만 파일 정리와 비용 정산이 남았고, 서버가 재시작했습니다. 여기에 브라우저는 완료 이벤트를 놓쳤습니다. 각 계층이 무엇을 근거로 복구해야 하는지 설계하세요.
완료 기준: 세 가지 장애 시점의 상태 전이표와 검증 계획을 만듭니다.
run-completion.ts, recovery.ts, Rust의 state.rs,
LiveUpdatesProvider.tsx에서 각 책임에 대응하는 코드를 하나씩 연결합니다.
JavaScript 함수·배열·객체와 기본 HTML을 안다는 가정의 학습안입니다. 처음 보는 문법이 많으면 1~2주차를 늘려도 좋습니다. 매주 하나의 동작을 설명하는 작은 결과물을 남기면 큰 저장소를 이해한 범위가 눈에 보입니다.
| 주차 | 배울 내용 | 연결할 코드 | 주간 결과물 |
|---|---|---|---|
| 1주차 | TypeScript 객체·유니언·제네릭, Zod 입력 검증 | packages/shared/src/validators/issue.ts | 정상·잘못된 checkout JSON 5개와 실패 이유 |
| 2주차 | HTTP, 미들웨어, 인증과 인가, 서비스 분리 | server/src/middleware/validate.ts·server/src/routes/issues.ts | 요청부터 서비스 호출까지의 흐름도 |
| 3주차 | 관계형 모델, 인덱스, 조건부 UPDATE, 트랜잭션 격리 | packages/db/src/schema/issues.ts·서비스 checkout() | 두 연결의 업무 선점 경쟁 결과표 |
| 4주차 | React 상태, 서버 캐시, WebSocket, 재연결 | ui/src/context/LiveUpdatesProvider.tsx | 정상·중복·누락 이벤트의 캐시 변화표 |
| 5주차 | 전략 패턴, 실행 상태 전이, 취소·재시도 | packages/adapter-utils/src/types.ts·server/src/services/heartbeat/ | 가짜 실행기와 실패·중지 시나리오 |
| 6주차 | Rust의 Result·소유권 기초, 영속 저장, CI 검증 범위 | packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs·.github/workflows/pr-trusted.yml | 재시작 복구 설계와 검증 계획 |
앞의 3주는 데이터와 요청의 규칙을 이해하는 데 쓰고, 뒤의 3주는 여러 실행과 화면이 그 규칙을 공유하는 방법을 살펴봅니다. Rust 문법에서 막히면 파일 저장의 순서부터 한국어로 적은 뒤 각 호출에 대응시켜 보세요.
.where()·.returning()이 SQL에서 무엇인지 적습니다.“왜 같은 업무를 두 실행이 동시에 잡지 못하는가?”, “연결이 끊겼는데 왜 바로 실패 처리하지 않는가?”, “완료했는데 왜 정산·정리가 남을 수 있는가?”, “화면은 놓친 이벤트를 어떻게 복구하는가?” 파일과 조건문을 근거로 설명할 수 있다면 문법을 넘어 설계를 읽기 시작한 것입니다.
| 용어 | 쉬운 설명 |
|---|---|
| 에이전트 | 목표를 받고 모델과 도구로 작업하는 실행 주체. 여기서는 담당 업무·설정·상태를 가진 관리 대상입니다. |
| 오케스트레이션 | 여러 작업의 시작 조건·순서·협업·종료를 조율하는 것. 호출을 여러 번 하는 것보다 넓은 개념입니다. |
| 제어 평면 | 누가 어디에서 무엇을 실행할지 정하고 상태와 권한을 관리하는 부분입니다. |
| 하네스 (harness) | 모델에게 지침·도구·작업 환경을 제공하며 실행을 이어 가는 틀입니다. |
| 어댑터 | 서로 다른 제공자의 실행 방식과 결과를 공통 계약에 맞춰 연결하는 부품입니다. |
| Runner | 실제 실행 환경에서 프로세스와 세션·통신 등을 다루는 실행기입니다. |
| heartbeat | 이 저장소에서는 에이전트를 깨워 작업을 수행하게 하는 실행 흐름에 쓰는 이름입니다. 단순 생존 확인 신호만을 뜻하지 않습니다. |
| 깨우기 (wakeup) | 배정·후속 메시지·예약 등의 계기로 실행을 요청하는 것. 요청 접수가 곧 즉시 실행 허가는 아닙니다. |
| issue / run | 업무 / 그 업무를 하려는 실행 한 번. 같은 업무에 여러 실행 기록이 연결될 수 있습니다. |
| checkout | 이 문맥에서는 업무 소유권을 확보하는 동작입니다. Git 브랜치를 바꾸는 명령과 구분합니다. |
| 원자적 갱신 | 하나의 변경을 중간 상태 없이 적용하는 것. 상태 조건을 UPDATE에 넣어 경쟁을 제어할 수 있습니다. |
| 멱등성 | 같은 요청이 다시 와도 불필요한 추가 효과가 생기지 않는 성질입니다. |
| 인가 | 신원을 확인한 뒤, 그 주체가 이 회사·업무에서 이 행동을 해도 되는지 판단하는 것입니다. |
| 회사 범위 | 데이터와 권한을 특정 회사에 묶는 경계. 다른 회사의 ID를 넣어도 접근 검사를 통과해서는 안 됩니다. |
| 임대 (lease) | 일정 조건이나 기간 동안 리소스를 사용할 권리. 소유자가 사라질 때의 회수·인계 규칙도 필요합니다. |
| 상태 머신 | 가능한 상태와 상태 사이의 이동 조건을 명시한 모델입니다. |
| 영속성 | 프로세스가 끝나도 필요한 정보가 남는 성질입니다. 메모리의 Map만으로는 얻을 수 없습니다. |
| 재생 (replay) | 저장된 명령·이벤트를 다시 읽어 상태를 따라잡는 방식. UI 알림과 Runner 경로의 보장이 다릅니다. |
| 캐시 무효화 | 저장해 둔 데이터가 오래됐을 수 있다고 표시하는 것. 활성 쿼리의 재조회와 이어질 수 있습니다. |
| 예산 예약 | 실제 비용 확정 전, 진행할 실행을 위해 가용 예산 일부를 잡아 두는 개념입니다. |
| 정산 | 보고된 사용량·가격·중복 여부를 다뤄 비용 기록에 반영하는 과정입니다. |
| SHA-256 | 파일 내용에서 계산하는 해시값. 받은 내용이 예상과 같은지 비교하며, 접근 권한을 대신 판정하지는 않습니다. |
| MCP | AI 클라이언트와 도구·자원을 제공하는 서버를 연결하는 표준 프로토콜입니다. |
| 스키마 / 마이그레이션 | 데이터 구조의 정의 / 그 구조를 버전별로 바꾸는 절차입니다. |
| 잠금 파일 | 의존성 해석 결과를 기록한 파일. 선언한 버전 범위와 구분해서 읽어야 합니다. |
| 모의 구현 (mock) | 외부 서비스를 실제 호출하지 않고 정해진 응답·실패를 만들어 검증하는 대체물입니다. |
| CI | 변경이 들어올 때 정책·타입·테스트·빌드 같은 검사를 자동으로 수행하는 체계입니다. |
한 단어가 서로 다른 계층에서 다른 뜻으로 쓰일 수 있습니다.
예를 들어 업무의 done, 실행의 succeeded, 파일 정리의 완료는 같은 상태가 아닙니다.
로그를 읽을 때는 “무엇의 상태인가”를 먼저 확인하세요.
| 항목 | 기록 |
|---|---|
| 저장소 | paperclipai/paperclip |
| 선정 기준 | GitHub Trending Monthly #1 · 2026-10-23 분석 |
| 스타 / 포크 | 106,337 / 17,680 · 2026-10-23 GitHub 기준 |
| 생성일 | 2026-03-02 |
| 주 언어 / 라이선스 | TypeScript / MIT · 루트 LICENSE에서도 확인 |
| 최신 릴리스 | v2026.1012.0 · 2026-10-12 |
| 최근 push | 2026-10-23 · GitHub 기준 |
| 분석 기준 최근 커밋 | 412914c · 2026-10-22 23:38:09 -07:00 · 실행 호스트에서 native 플러그인 업무 출력 파일 읽기 (#17285) |
| 커밋의 한국 시각 | 2026-10-23 15:38:09 +09:00 · 날짜 차이는 시간대에 따른 것 |
| 내부 패키지 버전 | 서버·UI·DB 등 0.3.1 · 날짜형 릴리스 태그와 별도 값 |
| 코드 규모 | TS·TSX 2,607,502줄 / Rust 80,775줄. 집계 대상 11개 확장자 전체 2,822,544줄·9,395파일 |
| 집계 범위 | 테스트·스토리·생성 코드·빈 줄·주석 포함. 의존성·빌드·캐시 디렉토리와 Markdown 등 문서 형식·JSON·잠금 파일 제외 |
README의 “회사를 운영한다”는 표현은 제품의 지향점입니다. 실제 업무 성과·완전 자율성·수익을 보장하지 않습니다. 트렌딩 순위와 스타 수 역시 성능·보안·사용자 수의 측정값이 아닙니다. 주목받는 이유에 관한 해석과 구현에서 확인되는 조건을 구분해서 읽어야 합니다.
설명한 경로는 커밋 412914c 기준이며, 더 최신 코드와 릴리스된 패키지는 다를 수 있습니다.
단일 커밋의 파일만으로 전체 커밋 수·기여자 비중·과거 구현의 변화까지 판단할 수 없습니다.
줄 수에는 생성 파일과 테스트가 포함되므로 유지보수 규모의 대략적인 지표로만 활용하세요.
공식 문서의 지원 범위와 실제 호스트에서의 실행 가능성도 별도로 확인해야 합니다. 본문의 CPU·RAM·디스크 권장은 추정이며 처리량·비용 상한의 실측 보증이 아닙니다. 회사 범위 검사와 복구 코드가 있다는 사실만으로 모든 배포의 격리·장애 복구가 자동으로 검증되는 것은 아닙니다.