SDK 동작 상세
@werft-ai/sdk가 재시도, 오류, 이벤트 스트림, turn 대기, close, push 승인에서 지키는 계약
@werft-ai/sdk가 HTTP API의 규칙을 어떻게 감싸는지 적는다. 사용법은 SDK 설치, 클라이언트 설정과 오류에 있고, 이 페이지는 그 아래의 세부 계약만 다룬다. HTTP API 자체의 규칙(scope, Idempotency-Key, 오류 코드, 이벤트 스트림)은 API 유즈케이스 가이드가 정본이다. 아래 내용은 packages/sdk의 코드에서 확인되는 것만 적었다.
클라이언트와 타입
apiKey는Authorization: Bearer <key>로만 보낸다. 오류 메시지,inspect, JSON 어디에도 나오지 않는다.- 타입은 OpenAPI에서 생성한 타입에서 이름만 붙여 가져온다. 응답 타입의 enum은 열린 union이라, 서버가 새 값(새 상태, 새 오류 코드)을 보내도 타입과 코드가 깨지지 않는다. 요청 타입은 닫혀 있다.
sessions.list와turns.list는await하면 첫 페이지,for await하면 모든 항목을 준다.page.nextPage()는 마지막 페이지에서null이다.- pause·resume·terminate·close의
expected_revision은 호출자가 넘긴다. SDK가 대신 읽어 채우지 않고,REVISION_CONFLICT도 자동으로 다시 보내지 않는다. limits.get(GET /v1/limits)은 로그인한 workspace owner만 읽는다. API key로 부르면 403이다.
재시도와 Idempotency-Key
- 재시도하는 경우: 응답 본문의
retryable: true, API 오류 본문이 아닌 502·504(프록시 응답), 응답을 읽지 못한 연결 실패와 시도 timeout.- 연결 실패·timeout과 API 오류 본문이 아닌 502·504는 GET과
Idempotency-Key가 있는 명령만 다시 보낸다. key가 없는openPullRequest·commentPullRequest는 서버가 이미 실행했을 수 있어 다시 보내지 않는다.
- 연결 실패·timeout과 API 오류 본문이 아닌 502·504는 GET과
- 재시도하지 않는 경우: 409 전부, 500. 그 밖의 API 오류는 응답 본문의
retryable이true일 때만 다시 보낸다. 4xx도 본문이retryable: true면 재시도한다(429 포함). - 기다리는 시간:
Retry-After가 있으면 그 값을 쓴다.maxRetryDelayMs(기본 60초)를 넘으면 기다리지 않고 바로 실패하고, 오류의retryAfterMs에 그 값을 싣는다. 없으면min(0.5초·2^n, 8초, maxRetryDelayMs)안에서 무작위로 기다린다. waitForTurn·waitForReceipt·waitForPushRequest는 재시도할 수 있는 읽기 실패를 다음 확인으로 넘긴다. 그 오류에Retry-After가 있으면 다음 확인은 그 시간이 지난 뒤이고, 이벤트가 와도 앞당기지 않는다.- key가 필요한 명령은 호출마다 key를 하나 정하고(직접 넘긴
idempotencyKey가 있으면 그것, 없으면 새로 만든 값), 그 호출의 모든 재시도에 같은 key를 보낸다. 같은 key로 같은 요청을 다시 보내면 서버가 명령을 한 번만 실행한다. sessions.requests.answer의 기본 key는answer:<request_id>다. 요청 하나에는 답이 하나뿐이다.- 프로세스를 다시 띄운 뒤 이어서 재시도하려면 오류의
idempotencyKey를 저장해 두었다가{ idempotencyKey }로 넘긴다.
오류
오류 클래스는 실패 방식마다 하나다. 서버 오류는 code로 가른다.
| 클래스 | 언제 | 주요 필드 |
|---|---|---|
APIError | 서버가 오류로 답했다 | status, code(서버 error.code 그대로), retryable, requestId, details, retryAfterMs |
ConnectionError | 응답을 읽지 못했다. 명령이 실행됐는지 모른다 | code: "NETWORK_ERROR" |
TimeoutError | 시도가 timeoutMs를 넘었다 | code: "TIMEOUT" |
AbortedError | 호출자의 signal이 abort했다 | name: "AbortError", cause는 signal의 reason |
TurnBlockedError | waitForTurn에서만. 세션이 paused·stopped·recovery_required·closed라 turn이 스스로 끝나지 않는다 | details.admission_state |
모두 WerftError를 잇고 attempts를 싣는다. key가 있는 명령의 오류는 abort까지 idempotencyKey를 싣는다. 단, 헤더 검증이 key를 정하기 전에 던지는 TypeError에는 없다.
import { isApiError } from "@werft-ai/sdk";
try {
await werft.sessions.pause(id, { expected_revision: revision });
} catch (error) {
if (isApiError(error, "REVISION_CONFLICT")) {
// 다른 사람이 세션을 바꿨다. 다시 읽고 판단한다.
}
throw error;
}이벤트 스트림
const stream = werft.sessions.events.stream(sessionId, { lastEventId, signal });
for await (const event of stream) {
console.log(event.kind, event.turn_id, event.payload);
}
// stream.lastEventId를 저장해 두면 다른 프로세스에서 그 뒤부터 받는다.- 끊기면
Last-Event-ID로 다시 연결해 그 이벤트 바로 뒤부터 받는다. - turn이나 세션이 끝나도 스스로 닫히지 않는다.
signal이나stream.close()로 끝낸다. - 400·401·403·404·410은 반복을 멈추고
APIError를 던진다. - 읽지 않은 이벤트가 10,000개를 넘으면, 받아 둔 이벤트를 모두 넘긴 뒤
code: "STREAM_OVERFLOW"인WerftError를 던진다. 넘긴 이벤트는 빈틈없이 이어지므로stream.lastEventId로 다시 열면 빠지는 이벤트가 없다. lastEventId없이 열면 세션 전체를 처음부터 다시 받는다. owner마다 열 수 있는 스트림 수에 한도가 있다(넘으면 429).
turn 대기와 권한 요청 응답
waitForTurn은 turn이 끝날 때까지 REST로 2초마다 확인한다. 같은 루프에서 세션의 대기 중인 요청을 handler에 넘긴다.
import { type PendingRequestHandlers, waitForTurn } from "@werft-ai/sdk";
const approvals: PendingRequestHandlers = {
permission(request) {
const command = (request.input as { command?: unknown } | null)?.command;
if (request.tool === "Bash" && command === "git status") return { action: "allow" };
return { action: "deny", reason: "This script only allows: git status" };
},
};
const turn = await waitForTurn(werft, sessionId, turnId, {
signal: AbortSignal.timeout(30 * 60_000),
onRequest: approvals,
onAnswer: (outcome) => console.log(outcome.request_id, outcome.outcome),
events: { onEvent: (event) => console.log(event.kind) }, // 선택: 이벤트가 오면 바로 깨어난다
});
console.log(turn.status); // "failed"도 오류가 아니라 값이다- 끝났다는 판단은
GET …/turns/{turn_id}의status가 terminal(completed·failed·interrupted·cancelled·outcome_unknown)이거나ended_at이 있을 때다.result이벤트는 완료 판정이 아니다. - handler는 보안 경계다. 정확히 일치하는 입력만 허락하고 나머지는 거절한다. 거절에는 타입상
reason이 필요하다. - handler가
undefined를 돌려주거나 그 kind의 handler가 없으면 요청을 열어 두고 콘솔이나 Slack의 사람에게 맡긴다. - 답의 결과(
AnswerOutcome):accepted(저장됨, receipt가 나중에 failed가 될 수 있다),left_open,too_late(REQUEST_EXPIRED·REQUEST_STALE),conflict(다른 답이 먼저 저장됨). - 답하려면 key에
sessions:approve가 있어야 한다. 없으면 403으로 대기가 실패한다. - 시한은
signal하나다. 질문을 열어 둔 채 기다리면 사람이 답할 때까지 기다린다. events를 켜면 스트림 하나를 더 쓴다. 스트림이 실패해도 polling으로 계속한다.
한 번만 처리하려면 answerPending(werft, sessionId, approvals)를 쓴다.
세션 닫기 (close)
할 일을 마친 세션을 끝낸다. terminate와 달리 아무것도 죽이지 않는다. 돌던 turn이 끝나고 checkpoint가 커밋된 뒤 세션이 closed가 된다. scope는 sessions:control이다.
import { closeBlocked, closeResult, waitForReceipt } from "@werft-ai/sdk";
const { revision } = await werft.sessions.get(sessionId);
const accepted = await werft.sessions.close(sessionId, {
expected_revision: revision,
reason: "script finished",
});
const receipt = await waitForReceipt(werft, accepted.receipt_id, {
signal: AbortSignal.timeout(10 * 60_000),
});
console.log(receipt.status, closeResult(receipt));
// { resulting_admission_state: "closed", checkpoint_revision: 3, cancelled_turn_count: 0 }- 닫는 동안 세션은
closing이다(isClosing(session)). 이때 보낸 메시지와 대부분의 제어는 409SESSION_CLOSING이다. terminate는 closing 중에도 받아들여 close를 앞지르고, close의 receipt는CONTROL_SUPERSEDED로 실패한다. - drain이 60초를 넘으면 세션
attention이CLOSE_BLOCKED가 된다(closeBlocked(session)이 그 reason을 준다). 열린 질문이나 승인 요청이 있으면 답하거나 turn을 interrupt할 때까지 close가 기다린다. 그래서waitForReceipt에는signal로 시한을 준다. - 대기 중이던 입력은 close가 커밋될 때 취소된다(
cancelled_turn_count). 닫힌 뒤의 메시지·제어는 409SESSION_CLOSED다. - 자기 turn 안에서 자기 세션을 닫는 에이전트는 receipt를 기다리지 않는다. drain이 그 turn이 끝나기를 기다리기 때문이다.
push 승인
import { waitForPushRequest } from "@werft-ai/sdk";
const push = await werft.sessions.pushRequests.create(sessionId);
// push.git_commit을 검토한 뒤, 승인 key로:
await approver.sessions.pushRequests.approve(sessionId, push.id, {
git_commit: push.git_commit,
});
const done = await waitForPushRequest(werft, sessionId, push.id, { signal });
if (done.state === "pushed")
await approver.sessions.pushRequests.openPullRequest(sessionId, push.id);SDK는 CHECKPOINT_MOVED를 따로 처리하지 않는다. 받아도 다시 읽어 다시 승인하지 않으므로, 검토한 commit만 승인한다.
버전
- SDK 버전(
SDK_VERSION)은 패키지 자신의 semver다. 게시 버전은npm view @werft-ai/sdk dist-tags로 본다. API_SPEC_VERSION은 타입을 생성한 스펙의info.version이다. 런타임에 서버 버전과 맞춰 보지는 않는다.- SSE envelope은
schema_version1만 읽는다(SUPPORTED_SSE_SCHEMA_VERSIONS).