# 워크플로와 프로젝트

> TypeScript에서 client.workflows로 Nodaro 워크플로를 만들고, 수정하고, 공유하고, 내보내고, 가져오고, 실행하며, client.projects로 프로젝트에 정리합니다.

Source: https://nodaro.ai/ko/docs/developers/sdk/workflows

<strong>`client.workflows`</strong>는 Nodaro 워크플로를 읽고, 쓰고, 공유하고, 실행하며, <strong>`client.projects`</strong>는 워크플로가 들어 있는 프로젝트를 관리합니다. <strong>`client.sessions`</strong>는 프로젝트 안에서 단일 생성을 세션으로 묶습니다. 워크플로는 연결된 노드로 이루어진 캔버스이고, 모든 워크플로는 프로젝트 하나에 속합니다. 이 메서드들은 [워크플로 REST API](https://nodaro.ai/docs/developers/api/workflows)와 같은 엔드포인트를 호출합니다. 개념은 [워크플로와 프로젝트](https://nodaro.ai/docs/concepts/workflows-and-projects)를 참고하세요.

## 메서드
| 메서드 | 기능 |
| --- | --- |
| [`workflows.list(params)`](#listparams) | 프로젝트의 워크플로를 그래프 없이 나열 |
| [`workflows.get(id)`](#getid) | 노드, 연결, 설정을 포함해 워크플로 하나 읽기 |
| [`workflows.getPublic(id)`](#getpublicid) | 링크로 공유된 워크플로를 토큰 없이 읽기 |
| [`workflows.create(input)`](#createinput) | 프로젝트에 워크플로 만들기 |
| [`workflows.update(id, input)`](#updateid-input) | 워크플로의 필드 변경 |
| [`workflows.delete(id)`](#deleteid) | 워크플로 삭제 |
| [`workflows.run(id, params?)`](#runid-params) | 워크플로 실행 시작 |
| [`workflows.renderFinal(id, params)`](#renderfinalid-params) | 미리 보기를 검토한 뒤 EDL 적용 렌더의 최종본 렌더링 |
| [`workflows.estimateRenderFinal(id, params)`](#estimaterenderfinalid-params) | 최종 렌더링을 시작하기 전에 견적 받기 |
| [`workflows.export(workflowId, opts?)`](#exportworkflowid-opts) | 워크플로를 이식 가능한 JSON 번들로 내보내기 |
| [`workflows.import(input)`](#importinput) | 번들을 프로젝트로 가져오기 |
| [`workflows.setVisibility(id, visibility)`](#setvisibilityid-visibility) | 워크플로를 비공개로 하거나 워크스페이스에 공개 |
| [`workflows.move(id, params)`](#moveid-params) | 워크플로를 다른 프로젝트로 옮기기 |
| [`workflows.sharedWithMe()`](#sharedwithme) | 다른 사람이 나와 공유한 워크플로 나열 |
| [`workflows.collaborators.*`](#collaborators) | 워크플로를 공유받은 사람을 나열, 추가, 변경, 제거 |
| [`projects.list()`](#projectslist) 외 | 프로젝트를 나열, 읽기, 만들기, 수정, 삭제 |
| [`sessions.create(input)`](#clientsessions) 외 | 프로젝트 안의 생성 세션과 그 세션이 담은 작업 |

## client.workflows
### list(params)
프로젝트의 워크플로를 나열합니다. 목록은 메타데이터만 반환하며 `nodes`, `edges`, `settings`, `sourcePrompt`는 빠집니다. 전체 그래프가 필요하면 `get()`으로 워크플로 하나를 읽으세요.

```ts
list(params: { projectId: string }): Promise<{ data: Workflow[] }>
```

<TypeTable
type={{
projectId: { type: 'string', required: true, description: "워크플로를 나열할 프로젝트입니다." },
}}
/>

```ts
const { data: workflows } = await client.workflows.list({ projectId })
for (const wf of workflows) console.log(wf.id, wf.name, wf.updatedAt)
```

내가 볼 수 없는 프로젝트이면 `NotFoundError`가 발생합니다.

### get(id)
전체 `nodes`, `edges`, `settings`를 포함해 워크플로 하나를 읽습니다.

```ts
get(id: string): Promise<{ data: Workflow }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
}}
/>

```ts
const { data: wf } = await client.workflows.get(workflowId)
console.log(`${wf.name}: ${wf.nodes?.length ?? 0} nodes, version ${wf.version}`)
```

워크플로를 수정할 계획이라면 `version`을 보관하세요. 다른 사람이 동시에 쓰더라도 업데이트를 안전하게 만들어 줍니다.

### getPublic(id)
소유자가 링크로 공유한 워크플로를 읽습니다(`GET /v1/public/workflows/:id`). 토큰은 필요 없습니다. 워크플로는 공유가 켜져 있는 동안에만 반환되며, 그렇지 않으면 메서드가 `NotFoundError`를 발생시킵니다. UGC 동영상은 소유자가 아닌 모든 사람에게 게시된 앱이 내보내는 형태로 반환됩니다. UGC 노드와 그 노드에서 만든 모든 노드는 결과 없이 반환되며, 소유자의 사진은 절대 포함되지 않습니다.

```ts
getPublic(id: string): Promise<{ data: Workflow }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "공유된 워크플로의 id입니다." },
}}
/>

```ts
const { data: shared } = await client.workflows.getPublic(workflowId)
```

인증 공급자가 토큰을 반환하지 않으면 요청은 토큰 없이 전송됩니다.

공유된 스튜디오 프로덕션은 소유자의 작업 상태 없이 반환됩니다. 휴지통, 진행 중인 실행, 비어 있는 미디어 슬롯, 저장하지 않은 편집기 초안은 `settings.studio`에서 빠집니다.

### create(input)
프로젝트에 워크플로를 만들고 전체 레코드를 반환합니다. 필요한 것은 `projectId`와 `name`뿐이며, 나머지 필드에는 서버 기본값이 적용됩니다.

```ts
create(input: CreateWorkflowInput): Promise<{ data: Workflow }>
```

<TypeTable
type={{
projectId: { type: 'string', required: true, description: "워크플로를 만들 프로젝트입니다." },
name: { type: 'string', required: true, description: "워크플로 이름입니다." },
description: { type: 'string', description: "설명입니다." },
folderId: { type: 'string | null', description: "프로젝트 안의 폴더입니다." },
nodes: { type: 'GenericNode[]', description: "캔버스의 노드입니다." },
edges: { type: 'GenericEdge[]', description: "노드 사이의 연결입니다." },
settings: { type: 'Record<string, unknown>', description: "워크플로 설정입니다." },
sourcePrompt: { type: 'string', description: "워크플로를 생성할 때 쓴 프롬프트입니다(있는 경우)." },
}}
/>

```ts
const { data: wf } = await client.workflows.create({
projectId,
name: "Product launch video",
nodes: [],
edges: [],
})
```

미리 만들어진 그래프가 필요하면 편집기에서 만든 워크플로를 내보내 그 `nodes`와 `edges`를 읽거나, [템플릿](https://nodaro.ai/docs/developers/sdk/apps-and-templates)을 복제하세요.

### update(id, input)
워크플로 필드의 일부를 바꾸고, 업데이트된 전체 레코드를 반환합니다(`PATCH /v1/workflows/:id`). 생략한 필드는 그대로 유지됩니다.

```ts
update(id: string, input: UpdateWorkflowInput): Promise<{ data: Workflow }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
name: { type: 'string', description: "새 이름입니다." },
description: { type: 'string', description: "새 설명입니다." },
folderId: { type: 'string | null', description: "워크플로를 같은 프로젝트의 다른 폴더로 옮깁니다." },
nodes: { type: 'GenericNode[]', description: "노드의 새 전체 목록입니다." },
edges: { type: 'GenericEdge[]', description: "연결의 새 전체 목록입니다." },
settings: { type: 'Record<string, unknown>', description: "새 워크플로 설정입니다." },
sourcePrompt: { type: 'string', description: "워크플로를 생성할 때 쓴 프롬프트입니다." },
thumbnailUrl: { type: 'string | null', description: "미리보기 이미지입니다. 이미 호스팅된 이미지의 URL을 주거나, null로 제거하세요." },
expectedVersion: { type: 'number', description: "읽어 온 버전입니다. 저장된 버전과 다르면 업데이트가 WorkflowConflictError로 거부됩니다. expectedUpdatedAt보다 이 방식을 권장합니다." },
expectedUpdatedAt: { type: 'string', description: "읽어 온 updatedAt 값입니다. expectedVersion을 대신하는 이전 방식입니다." },
}}
/>

```ts
await client.workflows.update(workflowId, { name: "Renamed", expectedVersion: 7 })
```

**안전한 업데이트.** `expectedVersion`이나 `expectedUpdatedAt`이 없으면 마지막에 쓴 내용이 남습니다. 둘 중 하나가 있으면, 내가 읽은 뒤로 아무도 워크플로를 바꾸지 않았을 때만 업데이트가 적용됩니다. 그렇지 않으면 [`WorkflowConflictError`](https://nodaro.ai/docs/developers/sdk/errors#workflowconflicterror)가 발생하며, 그 `currentRecord`에 현재 워크플로가 들어 있으므로 다시 읽지 않고 병합할 수 있습니다.

```ts

try {
await client.workflows.update(workflowId, { settings, expectedVersion: wf.version })
} catch (err) {
if (err instanceof WorkflowConflictError && err.currentRecord) {
const merged = mergeSettings(err.currentRecord.settings, settings)
await client.workflows.update(workflowId, {
settings: merged,
expectedVersion: err.currentVersion,
})
} else {
throw err
}
}
```

실행 중 상태나 현재 작업 id처럼 노드 데이터에 담긴 실행 상태 값은 서버가 제거하며, 저장되지 않습니다.

### delete(id)
워크플로를 삭제합니다.

```ts
delete(id: string): Promise<{ success: true }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
}}
/>

```ts
await client.workflows.delete(workflowId)
```

id가 없거나 내 것이 아니면 `NotFoundError`가 발생하므로, 삭제가 조용히 실패하는 일은 없습니다.

### run(id, params?)
워크플로 실행을 시작하고 `{ executionId, status }`와 함께 곧바로 반환합니다. 실행은 서버에서 계속됩니다. 진행 상황과 결과는 [`client.executions.get()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions)으로 폴링하세요.

```ts
run(id: string, params?: {
nodeIds?: string[]
inputOverrides?: Record<string, Record<string, unknown>>
continueFromExecutionId?: string
}): Promise<{ executionId: string; status: "pending" | "running" }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
nodeIds: { type: 'string[]', description: "이 노드만 실행합니다. 생략하면 워크플로 전체를 실행합니다." },
inputOverrides: { type: 'Record<string, Record<string, unknown>>', description: "이번 실행에만 적용할 노드 설정으로, 노드 id별로 지정합니다. 예: { n1: { prompt: '...' } }. 각 노드의 필드는 저장된 설정 위에 병합되며, 재정의된 노드는 저장된 결과를 다시 쓰지 않습니다. 출력 노드의 대상은 400 locked_field로 거부됩니다." },
continueFromExecutionId: { type: 'string', description: "처음부터 다시 시작하지 않고 이전 실행을 이어 갑니다. 이 워크플로에서 내가 실행해 완료된 실행이어야 하며, 함께 nodeIds가 필요합니다." },
}}
/>

```ts
const { executionId } = await client.workflows.run(workflowId, {
nodeIds: ["text-prompt-1", "image-gen-2"],
})
const { data: execution } = await client.executions.get(executionId)
```

- 계정이 실행에 들 수 있는 최대 비용을 감당할 수 없으면 `InsufficientCreditsError`가 발생합니다.
- OAuth 토큰에는 `workflows:execute` 범위가 필요합니다.
- 워크스페이스에서 워크플로를 실행하려면 [`client.withWorkspace()`](https://nodaro.ai/docs/developers/sdk/client#withworkspaceworkspaceid)로 얻은 클라이언트에서 `run`을 호출하세요.

**이전 실행 이어 가기.** `continueFromExecutionId`를 사용하면 `nodeIds`에 있는 노드만 실행됩니다. 다른 모든 노드는 워크플로에 저장된 결과가 아니라 이전 실행이 만든 결과를 넘겨줍니다. **편집 계획**은 현재 검토 내용을 적용한 계획을 넘겨줍니다. 이전 실행의 `inputOverrides`가 다시 적용되고, 지금 보내는 값이 필드별로 그 위에 적용됩니다.

```ts
await client.workflows.run(workflowId, {
nodeIds: [renderNodeId, captionsNodeId],
inputOverrides: { [renderNodeId]: { quality: "final" } },
continueFromExecutionId: previewRunId,
})
```

거부된 이어 가기는 실행이 만들어지기 전에 오류를 발생시킵니다. 이전 실행을 찾을 수 없으면 코드가 `continuation_not_found`인 `NotFoundError`가 발생합니다. 그 밖의 경우에는 `code`가 `continuation_subset_required`(`nodeIds` 없음), `continuation_workflow_mismatch`, `continuation_version_mismatch`, `continuation_not_completed` 중 하나인 `NodaroError`가 발생합니다.

미리 보기에서 실행을 멈추고 검토하는 설치에서는, [EDL 적용](https://nodaro.ai/docs/nodes/video/apply-edl#preview-and-render-final) 렌더가 미리 보기로 설정된 워크플로의 실행이 `400 preview_review_required`로 거부됩니다. SDK 실행은 아무도 검토하지 않기 때문입니다. 그 실행에서만 `inputOverrides: { [renderNodeId]: { quality: "final" } }`로 렌더를 최종으로 설정하세요. Nodaro Cloud에서는 실행이 미리 보기에서 멈추지 않으므로 이 거부가 일어나지 않습니다.

### renderFinal(id, params)
미리 보기를 만든 [EDL 적용](https://nodaro.ai/docs/nodes/video/apply-edl#preview-and-render-final) 렌더의 최종본을, 내가 검토한 뒤 렌더링합니다. 편집기의 **최종 렌더링** 버튼과 같은 작업을 실행합니다. 이번 실행에서만 렌더를 최종으로 실행한 다음, 그 뒤의 모든 노드를 실행합니다. 노드는 자신의 **품질** 설정을 유지합니다. **편집 계획**과 렌더 사이에 **카메라 전환**이 있으면 **카메라 전환**이 먼저 다시 실행됩니다.

```ts
renderFinal(id: string, params: { renderNodeId: string; continueFromExecutionId: string }): Promise<{ executionId: string; status: "pending" | "running" }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
renderNodeId: { type: 'string', required: true, description: "최종본을 렌더링할 EDL 적용 렌더 노드입니다." },
continueFromExecutionId: { type: 'string', required: true, description: "미리 보기를 만든 실행입니다. 이 워크플로에서 내가 실행해 완료된 실행이어야 합니다." },
}}
/>

서버가 편집기와 같은 규칙으로 실행할 노드를 정하므로, 렌더와 이전 실행만 보내면 됩니다. 실행되지 않는 모든 노드는 이전 실행이 만든 결과를 넘겨줍니다.

```ts
const { executionId } = await client.workflows.renderFinal(workflowId, {
renderNodeId,
continueFromExecutionId: previewRunId,
})
```

- 존재하지 않는 노드 id는 `render_final_node_not_found`로, EDL 적용 렌더가 아닌 노드는 `render_final_not_a_render`로 거부됩니다. [`run()`](#runid-params)의 이어 가기 코드도 적용됩니다.
- 비용을 내는 쪽이 감당할 수 없는 실행은 실행이 만들어지기 전에 `required`와 `available`을 담은 `InsufficientCreditsError`(`402`)를 발생시킵니다.
- OAuth 토큰에는 `workflows:execute` 범위가 필요합니다.

### estimateRenderFinal(id, params)
[`renderFinal()`](#renderfinalid-params)의 견적을 내며 아무것도 만들지 않습니다. 같은 매개변수를 받습니다.

```ts
estimateRenderFinal(id: string, params: { renderNodeId: string; continueFromExecutionId: string }): Promise<{ data: RenderFinalQuote }>
```

```ts
const { data: quote } = await client.workflows.estimateRenderFinal(workflowId, {
renderNodeId,
continueFromExecutionId: previewRunId,
})
if (quote.sufficient) {
await client.workflows.renderFinal(workflowId, { renderNodeId, continueFromExecutionId: previewRunId })
}
```

`RenderFinalQuote`에는 `renderNodeId`, `nodeIds`(실행될 노드), `inputOverrides`(실행에 쓰이는 재정의), `estimatedCredits`, `sufficient`(비용을 내는 쪽이 감당할 수 있는지), `available`(비용을 내는 쪽이 쓸 수 있는 크레딧)이 있습니다. 워크스페이스 예산이 비용을 내면 `available`은 `null`입니다. 크레딧이 없는 에디션에서는 수치가 `null`입니다.

### export(workflowId, opts?)
워크플로의 저장된 버전을 이식 가능한 JSON 번들로 내보냅니다. 편집기의 **내보내기** 메뉴가 쓰는 것과 같은 파일 형식입니다. `assets: true`를 주면 편집기의 **에셋 포함**처럼, 워크플로가 사용하는 에셋도 번들에 담깁니다.

```ts
export(workflowId: string, opts?: { assets?: boolean }): Promise<{ data: WorkflowExport }>
```

<TypeTable
type={{
workflowId: { type: 'string', required: true, description: "내보낼 워크플로입니다." },
assets: { type: 'boolean', default: 'false', description: "워크플로가 사용하는 캐릭터, 오브젝트, 크리처, 장소를 포함해 가져올 때 다시 만들어지게 합니다." },
}}
/>

```ts
const { data: bundle } = await client.workflows.export(workflowId, { assets: true })
```

**이식성.** 번들은 다른 인스턴스가 가져올 수 있는 미디어만 가리킬 수 있습니다. 노드가 다른 인스턴스에서 접근할 수 없는 URL을 쓰면, 예를 들어 `localhost`에 있는 셀프 호스팅 설치의 자체 스토리지, 로컬 네트워크 주소, `.internal` 이름을 쓰면, 번들은 그 URL을 `portability.unreachableMedia` 아래에 나열합니다.

```ts
bundle.portability?.unreachableMedia
// [{ nodeId: "n1", nodeLabel: "Video URL", field: "videoUrl", url: "http://localhost:3000/storage/..." }]
```

모든 미디어 URL이 공개되어 있으면 이 필드는 없습니다. 이런 번들도 가져올 수는 있지만, 해당 노드는 미디어를 다른 인스턴스에 다시 업로드할 때까지 그곳에서 실행할 수 없습니다. [가져오기와 내보내기](https://nodaro.ai/docs/guides/import-export)를 참고하세요.

### import(input)
내보낸 번들을 내 프로젝트 중 하나로 가져오고 새 워크플로를 반환합니다.

```ts
import(input: WorkflowExport & { projectId: string }): Promise<{
data: Workflow
importReport?: WorkflowImportReport
}>
```

<TypeTable
type={{
projectId: { type: 'string', required: true, description: "가져올 대상 프로젝트입니다." },
'...bundle': { type: 'WorkflowExport', required: true, description: "내보낸 번들의 필드로, 입력에 펼쳐 넣습니다." },
}}
/>

```ts
const { data: wf, importReport } = await client.workflows.import({ ...bundle, projectId })
```

가져오기가 하는 일:

- **에셋이 다시 만들어집니다.** 번들에 있는 캐릭터, 오브젝트, 크리처, 장소는 내 계정의 새 항목이 됩니다. 워크플로는 에셋 노드, 노드 데이터의 모든 `@` 멘션, 워크플로 설정에서 그 새 항목을 가리키도록 바뀝니다.
- **미디어가 복사됩니다.** 다른 호스트에 있는 미디어는 접근할 수 있으면 이 인스턴스의 스토리지로 복사되므로, 워크플로가 다른 사람의 서버에 의존하지 않습니다. 한도는 워크플로의 미디어 25개와 번들된 에셋용 25개이며, 이미지는 최대 20MB, 동영상과 오디오는 최대 50MB입니다.
- **복사본은 내 스토리지 용량에 포함됩니다.** 스토리지가 부족해도 워크플로는 가져와지며, 들어가지 못한 에셋은 보고서에 나열됩니다.

`importReport`가 결과를 알려 줍니다.

```ts
importReport
// {
//   rehosted: 3,                                    // files copied to this instance
//   unreachable: [{ nodeId, nodeLabel, field, url }], // private hosts, left as they were
//   skipped: [{ nodeId, field, url, reason: "HTTP 404" }],
//   assetIdMap: { "<bundled asset id>": "<new asset id>" },
//   assetsSkipped: [{ kind: "character", id, name: "Kira", reason: "Storage limit exceeded" }],
// }
```

`assetIdMap`은 번들에 에셋이 담겨 있으면 항상 있습니다. 서버가 워크플로 안의 모든 멘션을 이미 업데이트했으니, 이 맵은 워크플로 밖에 보관하는 참조에만 쓰세요. `assetsSkipped`는 빠진 항목이 있을 때만 나타납니다.

### setVisibility(id, visibility)
워크플로를 만든 사람과 내가 추가한 공동 작업자만 볼 수 있는 `"private"`이나, 워크스페이스의 모든 사람이 볼 수 있는 `"workspace"`로 설정합니다.

```ts
setVisibility(id: string, visibility: "private" | "workspace"): Promise<{ data: Workflow }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
visibility: { type: '"private" | "workspace"', required: true, description: "워크플로를 볼 수 있는 사람입니다." },
}}
/>

```ts
await client.workflows.setVisibility(workflowId, "workspace")
```

만든 사람이나 워크스페이스 관리자만 바꿀 수 있으며, 그 밖의 사람은 `ForbiddenError`를 받습니다. 워크스페이스는 Nodaro Cloud의 [조직](https://nodaro.ai/docs/developers/sdk/organizations)에 있습니다.

### move(id, params)
워크플로를 다른 프로젝트로 옮깁니다(`POST /v1/workflows/:id/move`). 워크플로는 원래 폴더에서 빠집니다.

```ts
move(id: string, params: { projectId: string }): Promise<{
data: Workflow
droppedCollaborators: { userId: string; name: string | null }[]
}>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "워크플로 id입니다." },
projectId: { type: 'string', required: true, description: "옮길 대상 프로젝트입니다." },
}}
/>

```ts
const { droppedCollaborators } = await client.workflows.move(workflowId, { projectId: archiveProjectId })
```

이동으로 워크플로가 워크스페이스 밖으로 나가면, 그 워크스페이스에서 받은 접근 권한은 사라집니다. 해당하는 사람은 `droppedCollaborators`로 반환됩니다.

### sharedWithMe()
다른 사람이 나와 직접 공유한 워크플로를 나열합니다. 내가 속한 워크스페이스의 작업물은 이미 그 워크스페이스의 목록에 나타나므로 포함되지 않습니다. 각 워크플로에는 내가 가진 `grantedRole`이 담겨 있습니다.

```ts
sharedWithMe(): Promise<{ data: (Workflow & { grantedRole: "viewer" | "editor" })[] }>
```

```ts
const { data: shared } = await client.workflows.sharedWithMe()
for (const wf of shared) console.log(wf.name, wf.grantedRole)
```

조직이 없는 인스턴스에서는 목록이 비어 있습니다.

### collaborators
워크플로를 공유받은 사람들로, `client.workflows.collaborators`로 접근합니다. 이 메서드들은 [조직](https://nodaro.ai/docs/developers/sdk/organizations)이 있는 인스턴스에만 있으며, 그 밖의 인스턴스에서는 `NotFoundError`를 발생시킵니다.

```ts
collaborators.list(workflowId: string): Promise<{ data: Collaborator[] }>
collaborators.add(workflowId: string, input: { userId?: string; email?: string; role: "viewer" | "editor" }): Promise<{ data: { userId: string; role: "viewer" | "editor" } }>
collaborators.update(workflowId: string, userId: string, input: { role: "viewer" | "editor" }): Promise<{ data: { userId: string; role: "viewer" | "editor" } }>
collaborators.remove(workflowId: string, userId: string): Promise<{ success: true }>
```

<TypeTable
type={{
workflowId: { type: 'string', required: true, description: "워크플로 id입니다." },
userId: { type: 'string', description: "그 사람의 사용자 id입니다. add()에서는 userId와 email 중 정확히 하나만 주세요." },
email: { type: 'string', description: "조직과 관계없이 모든 Nodaro 계정의 이메일 주소입니다. 계정이 없는 주소이면 NotFoundError가 발생합니다." },
role: { type: '"viewer" | "editor"', required: true, description: "그 사람이 워크플로에서 할 수 있는 일입니다." },
}}
/>

```ts
await client.workflows.collaborators.add(workflowId, { email: "editor@example.com", role: "editor" })

const { data: people } = await client.workflows.collaborators.list(workflowId)
// [{ userId, name, avatar, role }]: email addresses are never returned
```

공동 작업자는 자신의 사용자 id로 `remove()`를 호출해 워크플로에서 나갈 수 있습니다.

## client.projects
프로젝트는 워크플로를 묶습니다. 모든 워크플로는 정확히 하나의 프로젝트에 속합니다.

### projects.list()
내 프로젝트를 나열합니다.

```ts
list(): Promise<{ data: Project[] }>
```

```ts
const { data: projects } = await client.projects.list()
```

### projects.get(id)
프로젝트 하나를 읽습니다.

```ts
get(id: string): Promise<{ data: Project }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로젝트 id입니다." },
}}
/>

```ts
const { data: project } = await client.projects.get(projectId)
```

### projects.create(input)
프로젝트를 만듭니다.

```ts
create(input: { name: string; description?: string; settings?: Record<string, unknown> }): Promise<{ data: Project }>
```

<TypeTable
type={{
name: { type: 'string', required: true, description: "프로젝트 이름입니다." },
description: { type: 'string', description: "설명입니다." },
settings: { type: 'Record<string, unknown>', description: "프로젝트 설정입니다." },
}}
/>

```ts
const { data: project } = await client.projects.create({ name: "Spring campaign" })
```

### projects.update(id, input)
프로젝트를 변경합니다. 필드를 하나 이상 전달하세요.

```ts
update(id: string, input: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<{ data: Project }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로젝트 id입니다." },
name: { type: 'string', description: "새 이름입니다." },
description: { type: 'string', description: "새 설명입니다." },
settings: { type: 'Record<string, unknown>', description: "새 프로젝트 설정입니다." },
}}
/>

```ts
await client.projects.update(projectId, { description: "Assets for the spring launch" })
```

### projects.delete(id)
프로젝트를 삭제합니다.

```ts
delete(id: string): Promise<{ success: true }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "프로젝트 id입니다." },
}}
/>

```ts
await client.projects.delete(projectId)
```

## client.sessions
**생성 세션**은 프로젝트 안에서 단일 생성을 묶으며, 모든 기기에서 똑같이 보입니다. 프로젝트는 세션을 담고, 각 세션은 항목을 담습니다. 세션의 프로젝트는 만들 때 정해집니다. 각 항목은 내 작업 하나의 사본이 아니라 링크입니다.

```ts
const { data: session } = await client.sessions.create({ projectId, tool: "create-image", title: "Beach" })

// A job joins a session when the generation request names it.
await client.nodes.runAndWait("generate-image", {
prompt: "a red car",
sessionId: session.id,
origin: "create-image",
})

const { data: sessions, next } = await client.sessions.list({ projectId })
const { data: items } = await client.sessions.items(session.id)
```

| 메서드 | 시그니처 | 참고 |
| --- | --- | --- |
| `create` | `create(input: CreateSessionInput): Promise<{ data: GenerationSession }>` | `tool`은 필수이며, 예를 들어 `create-image`입니다. `projectId`와 `title`은 선택 사항입니다. |
| `list` | `list(params?: ListSessionsParams): Promise<SessionPage<GenerationSession>>` | 최신순입니다. 필터는 `projectId`, `tool`, `includeArchived`, `limit`, `cursor`입니다. 각 세션에는 `itemCount`와 최대 4개의 `previewUrls`가 담깁니다. |
| `get` | `get(id: string): Promise<{ data: GenerationSession }>` | 세션 하나입니다. |
| `update` | `update(id: string, input: UpdateSessionInput): Promise<{ data: GenerationSession }>` | `title`(`null`이면 제목 없음)과 `archived`입니다. 프로젝트는 바꿀 수 없습니다. |
| `delete` | `delete(id: string): Promise<{ success: true }>` | 세션을 삭제합니다. 세션의 작업은 유지되며, 그 작업을 담은 다른 모든 세션에도 그대로 남습니다. |
| `items` | `items(id: string, params?: ListSessionItemsParams): Promise<SessionPage<SessionItem>>` | 피드이며, 최신순입니다. 각 항목에는 `jobId`, `addedAt`, `imported`, `importedFrom`, `job`이 있습니다. |
| `importItems` | `importItems(id: string, input: { jobIds: string[] }): Promise<{ data: { imported: number; requested: number } }>` | **내** 작업을 최대 100개까지 세션에 연결합니다. 전부 성공하거나 전부 실패합니다. 내 것이 아닌 작업이 하나라도 있으면 호출이 404로 실패합니다. |
| `removeItem` | `removeItem(id: string, jobId: string): Promise<{ success: true }>` | 링크만 제거합니다. 작업과, 그 작업을 담은 다른 모든 세션은 그대로입니다. |

프로젝트 사이의 이동은 없습니다. 작업물을 다른 프로젝트에 넣으려면 그 프로젝트의 세션에서 `importItems()`를 호출한 다음, 이전 세션에서 `removeItem()`을 호출하세요.

작업을 만드는 모든 요청은 `sessionId`와 `origin`을 받으며, 플랫폼은 이를 작업에 기록합니다. 이 필드는 `GenerateImageParams`, `GenerateVideoParams`, `TextToSpeechParams` 같은 모든 생성 매개변수 타입과, 캐릭터, 오브젝트, 장소, 크리처의 `generate*` 입력에 타입이 지정되어 있습니다. `origin`은 슬러그여야 합니다. 소문자, 숫자, 대시로 된 최대 40자입니다. 다른 값은 `400 validation_error`로 실패합니다. 작업의 `session_id`는 `client.jobs.get()`과 `client.jobs.list()`에 나옵니다.

## 타입
| 타입 | 구조 |
| --- | --- |
| `Workflow` | `id`, `projectId`, `userId`, `name`, `description?`, `folderId?`, `version?`, `thumbnailUrl?`, `nodes?`, `edges?`, `settings?`, `sourcePrompt?`, `createdAt`, `updatedAt`. 그래프 필드는 전체 레코드에만 있습니다. |
| `RunWorkflowParams` | `nodeIds?`, `inputOverrides?`, `continueFromExecutionId?` |
| `RenderFinalParams` | `renderNodeId`, `continueFromExecutionId` |
| `RenderFinalQuote` | `renderNodeId`, `nodeIds`, `inputOverrides`, `estimatedCredits`, `sufficient`, `available` |
| `Project` | `id`, `userId`, `name`, `description?`, `settings?`, `createdAt`, `updatedAt` |
| `GenerationSession`, `SessionItem`, `SessionPage` | 생성 세션, 그 항목 하나, 그리고 둘 중 하나의 페이지 |
| `Collaborator` | `userId`, `name?`, `avatar?`, `role` |
| `WorkflowExport` | `export()`가 반환하고 `import()`가 받는 이식 가능한 번들 |
| `GenericNode`, `GenericEdge` | `nodes`와 `edges`에 쓰이는 노드와 연결의 구조 |

모두 `@nodaro/sdk`가 내보냅니다. [타입](https://nodaro.ai/docs/developers/sdk/types)을 참고하세요.

## 자주 묻는 질문

### 코드에서 Nodaro 워크플로를 실행하려면 어떻게 하나요?

client.workflows.run(workflowId)를 호출하세요. 곧바로 executionId가 반환됩니다. 상태가 completed, failed, cancelled, timed_out 중 하나가 될 때까지 client.executions.get(executionId)를 폴링하세요.

### 워크플로의 일부 노드만 실행할 수 있나요?

네. 실행할 노드 id 목록인 nodeIds를 담은 객체를 client.workflows.run의 두 번째 인수로 전달하세요. 그 노드만 실행됩니다.

### 코드에서 미리 보기의 최종본을 렌더링하려면 어떻게 하나요?

EDL 적용 렌더의 노드 id와 미리 보기를 만든 실행의 id로 client.workflows.renderFinal을 호출하세요. 렌더를 최종 품질로 실행하고 그 뒤의 모든 노드를 실행합니다. client.workflows.estimateRenderFinal로 먼저 견적을 받을 수 있습니다.

### 다른 사람의 변경 사항을 덮어쓰지 않으려면 어떻게 하나요?

읽어 온 버전을 expectedVersion으로 client.workflows.update에 전달하세요. 그사이에 워크플로가 바뀌었다면 호출은 현재 레코드를 담은 WorkflowConflictError를 발생시키므로, 병합한 다음 다시 시도할 수 있습니다.

### 워크플로를 다른 Nodaro 인스턴스로 복사하려면 어떻게 하나요?

assets 옵션을 true로 설정해 client.workflows.export로 내보낸 다음, 다른 인스턴스에서 client.workflows.import와 projectId로 번들을 가져오세요. 접근할 수 있는 미디어는 새 인스턴스의 스토리지로 복사됩니다.
