-
Notifications
You must be signed in to change notification settings - Fork 134
Expand file tree
/
Copy pathapi-client.ts
More file actions
460 lines (430 loc) · 17.3 KB
/
Copy pathapi-client.ts
File metadata and controls
460 lines (430 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
// altimate_change - new file
//
// Wire client for the workspace-binding endpoints in altimate-backend
// (/datamate-project-bindings/*, added by AI-8398). Shared between the TUI
// plugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx) and the
// `altimate link` CLI subcommand (packages/opencode/src/cli/cmd/link.ts) so
// the two entry points can't drift on request shape / error handling.
//
// Reads AltimateApi credentials on every call so an account switch is picked
// up immediately without a plugin restart. All FastAPI HTTPException.detail
// bodies come out as `{"detail": <string|object>}` — we parse the object form
// for 409/412 and surface it as a typed error rather than a bare status code.
import { AltimateApi } from "@/altimate/api/client"
const REQUEST_TIMEOUT_MS = 15_000
export interface DatamateRef {
id: number
name: string
/** Whether the workspace has memory switched on. Surfaced as a user-facing
* toggle in the workspace app, so callers that write memory must respect it.
* Undefined when the backend omitted the field. */
memoryEnabled?: boolean
}
export interface Binding {
id: number
datamate_id: number
datamate_name: string
/** Either ``repo_remote`` OR ``project_path`` is populated (at least one). */
repo_remote: string | null
project_path: string | null
created_at?: string
}
/** Project identifier passed to create/bind endpoints. At least one field is
* required by the backend's CHECK constraint; the CLI's resolveProjectIdentifier
* always populates ``projectPath`` and populates ``repoRemote`` when available. */
export interface ProjectIdentifier {
repoRemote?: string
projectPath?: string
}
export interface CreateAndBindResponse {
datamate: DatamateRef
binding: Binding
manage_url: string
}
export interface BindingResponse {
binding: Binding
}
export interface GetBindingResponse {
binding: Binding
datamate: DatamateRef
}
/** Which identifier arm the pre-check lookup actually matched on. Callers use
* this to pick the correct rebind endpoint (``/by-remote`` vs ``/by-path``)
* regardless of what the CURRENT identifier has — a repo whose remote was
* renamed still resolves via its ``project_path``, and a later ``rebindByRemote``
* would 404 because no binding exists under the new remote. (M3) */
export type MatchedIdentifier = "remote" | "path"
export interface ProjectBindingLookup extends GetBindingResponse {
matchedBy: MatchedIdentifier
}
export interface ConflictDetail {
message: string
existing_datamate_id?: number
existing_datamate_name?: string | null
repo_remote?: string
project_path?: string
}
export interface PreconditionDetail {
message: string
actual_current_datamate_id?: number
expected_current_datamate_id?: number
}
export class NotConfiguredError extends Error {
constructor() {
super("Altimate credentials not configured — sign in first.")
this.name = "NotConfiguredError"
}
}
export class ConflictError extends Error {
constructor(public readonly detail: ConflictDetail) {
super(detail.message)
this.name = "ConflictError"
}
}
export class PreconditionFailedError extends Error {
constructor(public readonly detail: PreconditionDetail) {
super(detail.message)
this.name = "PreconditionFailedError"
}
}
export class NotFoundError extends Error {
constructor(msg = "Not found") {
super(msg)
this.name = "NotFoundError"
}
}
export class ForbiddenError extends Error {
constructor(msg = "Forbidden") {
super(msg)
this.name = "ForbiddenError"
}
}
export class WorkspaceApiError extends Error {
constructor(
msg: string,
public readonly status?: number,
) {
super(msg)
this.name = "WorkspaceApiError"
}
}
async function creds(): Promise<{ url: string; instance: string; apiKey: string }> {
if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError()
const c = await AltimateApi.getCredentials()
return { url: c.altimateUrl, instance: c.altimateInstanceName, apiKey: c.altimateApiKey }
}
async function req<T>(
method: string,
subpath: string,
opts: {
body?: unknown
query?: Record<string, string>
/** Cap the response body. Off by default because this helper is shared and
* some endpoints legitimately return large payloads (memory ``/list``
* embeds block content and is not capped server-side). Set it where the
* body size is attacker- or accident-controlled, as skill file downloads
* are. */
boundResponse?: boolean
/** Override the base path prefix. Defaults to
* ``/datamate-project-bindings`` (this module's namespace). Pass e.g.
* ``/datamates`` to hit the sibling datamates_router through the same
* timeout / typed-error / empty-body machinery. */
base?: string
/** If true, a 2xx with an empty body returns ``undefined`` typed as T
* instead of throwing. Only set for endpoints known to return 204 or a
* bare 200 with no payload. */
allowEmptyBody?: boolean
} = {},
): Promise<T> {
const { url, instance, apiKey } = await creds()
const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : ""
const basePath = opts.base ?? "/datamate-project-bindings"
const target = `${url}${basePath}${subpath}${qs}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let res: Response
let text: string
try {
res = await fetch(target, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"x-tenant": instance,
},
signal: controller.signal,
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
})
// Keep the AbortController timeout ACTIVE while we read the response body.
// ``fetch()`` resolves after headers arrive; a server can send headers then
// stall the body stream indefinitely, so pulling the body inside the same
// try/finally is the difference between our 15s cap and hanging until TCP
// gives up. (CR round 2.) Do NOT wrap in ``.catch(() => "")`` — that
// swallows the AbortError from the timeout firing during the body read
// and turns a stalled response into a false "empty body". Rejection
// rethrows into the outer catch and is classified there. (cubic round 3.)
// Bound the body before buffering it. `res.text()` reads to completion, so
// a response far larger than advertised is an out-of-memory crash before
// any size check downstream can reject it. Content-Length is a hint, not a
// guarantee, so the stream is also cut off at the cap.
if (opts.boundResponse) {
const declared = Number(res.headers.get("content-length") ?? Number.NaN)
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES} limit`,
)
}
text = await readBounded(res, target)
} else {
text = await res.text()
}
} catch (err) {
// Distinguish "we hit our 15s abort" from "network stack failed" so the
// caller can decide differently (retry, longer timeout, offline banner).
// The abort fires equally when it kills the fetch OR the body read. (m8)
const name = (err as { name?: string } | undefined)?.name
if (name === "AbortError") {
throw new WorkspaceApiError(
`Request to ${target} timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s`,
)
}
const msg = err instanceof Error ? err.message : String(err)
throw new WorkspaceApiError(`Cannot reach ${target}: ${msg}`)
} finally {
clearTimeout(timeout)
}
let json: unknown = undefined
if (text) {
try {
json = JSON.parse(text)
} catch {
/* non-JSON body — surface as opaque via status code below */
}
}
const detail = (json as { detail?: unknown } | undefined)?.detail
if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found")
if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden")
if (res.status === 409) {
const d =
typeof detail === "object" && detail !== null
? (detail as ConflictDetail)
: { message: typeof detail === "string" ? detail : "Conflict" }
throw new ConflictError(d)
}
if (res.status === 412) {
const d =
typeof detail === "object" && detail !== null
? (detail as PreconditionDetail)
: { message: typeof detail === "string" ? detail : "Precondition failed" }
throw new PreconditionFailedError(d)
}
if (!res.ok) {
throw new WorkspaceApiError(
typeof detail === "string" ? detail : `Request failed with status ${res.status}`,
res.status,
)
}
// A 2xx with an empty (or unparseable) body is not the same as a resource.
// Callers dereference the return immediately (``.binding``, ``.datamate``,
// ``.manage_url``), so silently handing back ``undefined as T`` produces a
// ``TypeError`` inside caller code that the typed-error switches can't
// classify. Surface it as a WorkspaceApiError instead — unless the caller
// opted in via ``allowEmptyBody`` (e.g. 204 endpoints). Use ``== null`` so a
// literal ``JSON.parse("null")`` (which sets json to null, not undefined)
// is treated as an empty body too — otherwise ``null as T`` reaches callers
// and .foo throws in a way the typed switches can't classify. (m7 + CR)
if (json == null && !opts.allowEmptyBody) {
throw new WorkspaceApiError(
`Empty ${res.status} body from ${target} — expected JSON payload`,
res.status,
)
}
return json as T
}
/** Shared wire helper for sibling Altimate routers. Exported so callers that
* need the same credential resolution, abort budget and typed-error mapping do
* not duplicate any of it — see ./memory-api.ts, which drives
* ``/datamates/memory/*`` through this exact path. Always pass an explicit
* ``base``; the default is this module's own namespace. */
/** Ceiling on a single response body, applied ONLY where a caller opts in.
*
* Nothing upstream bounds what a workspace can hold and the body is buffered
* whole, so an oversized response is a process crash rather than a failed
* request. But this helper is shared: memory `/list` embeds block content and is
* deliberately not capped server-side, so a blanket limit would fail requests
* that work today. Skill file downloads opt in; everything else is unchanged. */
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024
/** Read a response body, refusing to buffer past the cap. */
async function readBounded(res: Response, target: string): Promise<string> {
// No stream to meter (a mocked or bodyless response): fall back to the
// unbounded read, then enforce the cap on what actually arrived so this
// branch cannot be used to bypass it.
if (!res.body) {
const whole = await res.text()
if (Buffer.byteLength(whole, "utf8") > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`,
)
}
return whole
}
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) {
throw new WorkspaceApiError(
`Response from ${target} exceeded the ${MAX_RESPONSE_BYTES} byte limit`,
)
}
chunks.push(value)
}
} finally {
reader.cancel().catch(() => {})
}
return new TextDecoder().decode(Buffer.concat(chunks))
}
export { req as altimateRequest }
export namespace WorkspaceApi {
/** Server-authoritative pre-check by git remote. Returns null on 404. */
export async function getBindingForRemote(remote: string): Promise<GetBindingResponse | null> {
try {
return await req<GetBindingResponse>("GET", "/by-remote", { query: { repo_remote: remote } })
} catch (err) {
if (err instanceof NotFoundError) return null
throw err
}
}
/** Symmetric pre-check by absolute project directory path (for projects
* without a git remote). Returns null on 404. */
export async function getBindingForPath(projectPath: string): Promise<GetBindingResponse | null> {
try {
return await req<GetBindingResponse>("GET", "/by-path", { query: { project_path: projectPath } })
} catch (err) {
if (err instanceof NotFoundError) return null
throw err
}
}
/** Tries remote first (stronger identity), then path. Returns the first hit
* TAGGED with which identifier matched, so a caller that later rebinds
* picks the right endpoint even if the current identifier's remote has
* changed since the binding was created (M3). Both fields on the
* identifier are optional but at least one must be present. */
export async function getBindingForProject(id: ProjectIdentifier): Promise<ProjectBindingLookup | null> {
if (id.repoRemote) {
const hit = await getBindingForRemote(id.repoRemote)
if (hit) return { ...hit, matchedBy: "remote" }
}
if (id.projectPath) {
const hit = await getBindingForPath(id.projectPath)
if (hit) return { ...hit, matchedBy: "path" }
}
return null
}
export async function createAndBind(input: {
name: string
identifier: ProjectIdentifier
description?: string
}): Promise<CreateAndBindResponse> {
return req<CreateAndBindResponse>("POST", "/", {
body: {
name: input.name,
repo_remote: input.identifier.repoRemote ?? null,
project_path: input.identifier.projectPath ?? null,
description: input.description ?? null,
},
})
}
export async function bindExisting(
datamateId: number,
identifier: ProjectIdentifier,
): Promise<BindingResponse> {
return req<BindingResponse>("POST", "/bind", {
body: {
datamate_id: datamateId,
repo_remote: identifier.repoRemote ?? null,
project_path: identifier.projectPath ?? null,
},
})
}
export async function rebindByRemote(input: {
remote: string
targetDatamateId: number
expectedCurrentDatamateId?: number
}): Promise<BindingResponse> {
return req<BindingResponse>("PUT", "/by-remote", {
body: {
repo_remote: input.remote,
target_datamate_id: input.targetDatamateId,
...(input.expectedCurrentDatamateId !== undefined
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
: {}),
},
})
}
/** Path-identified rebind — symmetric to ``rebindByRemote`` for projects
* without a git remote. */
export async function rebindByPath(input: {
projectPath: string
targetDatamateId: number
expectedCurrentDatamateId?: number
}): Promise<BindingResponse> {
return req<BindingResponse>("PUT", "/by-path", {
body: {
project_path: input.projectPath,
target_datamate_id: input.targetDatamateId,
...(input.expectedCurrentDatamateId !== undefined
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
: {}),
},
})
}
/** Populates the "link to existing workspace" picker. Reuses the existing
* ``/datamates/`` list endpoint on the datamates_router — routed through
* the shared ``req()`` machinery so it inherits the 15s abort, typed
* error mapping, empty-body guard, and detail-parsing everyone else
* gets. (M5) Filters out non-integer / non-positive ids so a corrupt row
* doesn't reach the picker as a "NaN" label that the caller then binds
* against. */
export async function listDatamates(): Promise<DatamateRef[]> {
// Accept THREE response envelopes — today's ``{datamates: [...]}``, a
// bare ``[...]``, and a generic ``{data: [...]}`` — so a backend
// contract change (or compat layer) doesn't silently empty the picker.
// (cubic-dev-ai round 3.)
type Row = { id: number | string; name: string; memory_enabled?: boolean }
const body = await req<Row[] | { datamates?: Row[]; data?: Row[] }>("GET", "/", {
base: "/datamates",
})
let rows: Row[]
if (Array.isArray(body)) {
rows = body
} else if (body && typeof body === "object") {
// Guard each envelope field with Array.isArray — a non-array
// ``datamates`` or ``data`` value (object / string / null) would
// otherwise slip through and throw on ``.map`` below, taking the
// picker down before it renders. (cubic round 4.)
rows = Array.isArray(body.datamates)
? body.datamates
: Array.isArray(body.data)
? body.data
: []
} else {
rows = []
}
// Filter valid row objects BEFORE map (Kilo cycle 5) — a single ``null``
// (or non-object) element in an otherwise-valid array would otherwise
// throw ``TypeError: Cannot read properties of null`` on ``d.id`` before
// the post-map filter can drop it. That's the exact picker-down failure
// the round-3/4 envelope guards were added to prevent, just from a
// per-element rather than per-envelope malformed value.
return rows
.filter((d): d is Row => d !== null && typeof d === "object")
.map((d) => ({ id: Number(d.id), name: d.name, memoryEnabled: d.memory_enabled }))
.filter((d) => Number.isInteger(d.id) && d.id > 0 && typeof d.name === "string")
}
}