@deepdockai/cli 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +271 -2
- package/SECURITY.md +7 -0
- package/dist/deepdock.js +13497 -0
- package/package.json +46 -4
- package/skill/SKILL.md +109 -0
- package/skill/references/auth-and-keys.md +102 -0
- package/skill/references/calling-actions.md +144 -0
- package/skill/references/cli.md +97 -0
- package/skill/references/clients/deepdock.py +163 -0
- package/skill/references/clients/deepdock.ts +244 -0
- package/skill/references/concepts.md +97 -0
- package/skill/references/discovery.md +80 -0
- package/skill/references/errors.md +124 -0
- package/skill/references/example-integration.md +159 -0
- package/skill/references/idempotency.md +97 -0
- package/skill/references/testing.md +80 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Minimal Deepdock /v1 client. Depends on httpx only (Python 3.9+).
|
|
2
|
+
|
|
3
|
+
It does the four things the OpenAPI document cannot tell you: the bearer header, the envelope
|
|
4
|
+
unwrap, the Idempotency-Key header with replay detection, and a typed error carrying the code.
|
|
5
|
+
Everything action-specific (paths, fields, methods) comes from the live OpenAPI document.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeepdockError(Exception):
|
|
18
|
+
"""Deepdock answered `{ "error": { "code", ... } }`. Switch on `code`, never on `message`."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None,
|
|
21
|
+
systems: Optional[List[str]] = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status = status
|
|
24
|
+
self.code = code
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
self.systems = systems
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DeepdockTransportError(Exception):
|
|
30
|
+
"""No answer, or not Deepdock's envelope (timeout, network, a proxy page): a write may still have landed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Page:
|
|
35
|
+
items: List[Dict[str, Any]]
|
|
36
|
+
next_cursor: Optional[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class WriteResult:
|
|
41
|
+
data: Dict[str, Any]
|
|
42
|
+
replayed: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Deepdock:
|
|
46
|
+
def __init__(self, api_key: str, base: str = "https://api.deepdock.ai", timeout: float = 30.0,
|
|
47
|
+
client: Optional[httpx.Client] = None):
|
|
48
|
+
# 30 s is above Deepdock's 20 s device wait, so device_offline arrives before a client timeout.
|
|
49
|
+
self.c = client or httpx.Client(base_url=base.rstrip("/"), timeout=timeout)
|
|
50
|
+
self.c.headers["Authorization"] = f"Bearer {api_key}"
|
|
51
|
+
|
|
52
|
+
def _send(self, method: str, path: str, **kw: Any) -> Tuple[Any, httpx.Headers]:
|
|
53
|
+
try:
|
|
54
|
+
r = self.c.request(method, path, **kw)
|
|
55
|
+
except httpx.HTTPError as e: # timeouts and network errors
|
|
56
|
+
raise DeepdockTransportError(f"no answer from Deepdock: {type(e).__name__}") from e
|
|
57
|
+
try:
|
|
58
|
+
body = r.json()
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise DeepdockTransportError(f"non-JSON answer with status {r.status_code}")
|
|
61
|
+
if isinstance(body, dict) and "error" in body:
|
|
62
|
+
e = body["error"]
|
|
63
|
+
raise DeepdockError(r.status_code, e["code"], e["message"], e.get("requestId"), e.get("systems"))
|
|
64
|
+
if not isinstance(body, dict) or "data" not in body:
|
|
65
|
+
raise DeepdockTransportError(f"unexpected body with status {r.status_code}")
|
|
66
|
+
return body["data"], r.headers
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _action_path(device_id: str, system: str, action: str) -> str:
|
|
70
|
+
return f"/v1/devices/{quote(device_id, safe='')}/systems/{quote(system, safe='')}/actions/{quote(action, safe='')}"
|
|
71
|
+
|
|
72
|
+
def read(self, device_id: str, system: str, action: str, params: Dict[str, str],
|
|
73
|
+
limit: Optional[int] = None, cursor: Optional[str] = None) -> Page:
|
|
74
|
+
"""Runs a read action. Pass `next_cursor` back as `cursor` for the next page."""
|
|
75
|
+
query: Dict[str, str] = dict(params)
|
|
76
|
+
if limit is not None:
|
|
77
|
+
query["limit"] = str(limit)
|
|
78
|
+
if cursor is not None:
|
|
79
|
+
query["cursor"] = cursor
|
|
80
|
+
data, _ = self._send("GET", self._action_path(device_id, system, action), params=query)
|
|
81
|
+
return Page(items=data["items"], next_cursor=data.get("nextCursor"))
|
|
82
|
+
|
|
83
|
+
def read_all(self, device_id: str, system: str, action: str, params: Dict[str, str],
|
|
84
|
+
limit: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
85
|
+
"""Reads every page of a read action, in order."""
|
|
86
|
+
cursor: Optional[str] = None
|
|
87
|
+
while True:
|
|
88
|
+
page = self.read(device_id, system, action, params, limit=limit, cursor=cursor)
|
|
89
|
+
yield from page.items
|
|
90
|
+
if page.next_cursor is None:
|
|
91
|
+
return
|
|
92
|
+
cursor = page.next_cursor
|
|
93
|
+
|
|
94
|
+
def write(self, device_id: str, system: str, action: str, method: str, idempotency_key: str,
|
|
95
|
+
body: Dict[str, str]) -> WriteResult:
|
|
96
|
+
"""Runs a write action. `method` is POST or PATCH from the action index; `body` values are all strings."""
|
|
97
|
+
data, headers = self._send(method, self._action_path(device_id, system, action), json=body,
|
|
98
|
+
headers={"Idempotency-Key": idempotency_key})
|
|
99
|
+
return WriteResult(data=data, replayed=headers.get("Idempotent-Replayed") == "true")
|
|
100
|
+
|
|
101
|
+
def systems(self, device_id: str) -> Dict[str, Any]:
|
|
102
|
+
"""The device's systems and each action's state. A live call: can answer device_offline."""
|
|
103
|
+
data, _ = self._send("GET", f"/v1/devices/{quote(device_id, safe='')}/systems")
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
def pair(self, code: str, label: Optional[str] = None) -> Dict[str, Any]:
|
|
107
|
+
"""Claims a device by the pairing code it shows. Keep the returned `id`."""
|
|
108
|
+
body: Dict[str, str] = {"code": code}
|
|
109
|
+
if label is not None:
|
|
110
|
+
body["label"] = label
|
|
111
|
+
data, _ = self._send("POST", "/v1/devices", json=body)
|
|
112
|
+
return data
|
|
113
|
+
|
|
114
|
+
def release(self, device_id: str) -> None:
|
|
115
|
+
self._send("DELETE", f"/v1/devices/{quote(device_id, safe='')}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------------------------------
|
|
119
|
+
# Pending writes: one attempt against persisted state. Persist the row BEFORE the first send, so a
|
|
120
|
+
# crash between send and answer cannot produce a second record under a new key. A job retries rows
|
|
121
|
+
# left in "sent" with backoff (seconds, then minutes, for hours if the device stays offline).
|
|
122
|
+
# --------------------------------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
CONFLICT_RETRIES = 5 # a running write finishes within seconds; beyond this, treat it as unknown
|
|
125
|
+
|
|
126
|
+
RETRY_SAME_KEY = {"device_offline", "target_busy", "temporarily_unavailable", "internal_error",
|
|
127
|
+
"action_paused", "system_unavailable"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class PendingWrite:
|
|
132
|
+
key: str # 8 to 128 chars of A-Z a-z 0-9 _ -, stable per business event
|
|
133
|
+
device_id: str
|
|
134
|
+
system: str
|
|
135
|
+
action: str
|
|
136
|
+
method: str
|
|
137
|
+
input: Dict[str, str]
|
|
138
|
+
status: str = "pending" # pending | sent | done | unknown | refused
|
|
139
|
+
conflict_retries: int = 0
|
|
140
|
+
result: Optional[Dict[str, Any]] = field(default=None)
|
|
141
|
+
request_id: Optional[str] = None
|
|
142
|
+
refused_code: Optional[str] = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def attempt(dd: Deepdock, row: PendingWrite) -> PendingWrite:
|
|
146
|
+
"""One attempt; returns the row with its new status. The caller persists it."""
|
|
147
|
+
try:
|
|
148
|
+
res = dd.write(row.device_id, row.system, row.action, row.method, row.key, row.input)
|
|
149
|
+
return replace(row, status="done", result=res.data)
|
|
150
|
+
except DeepdockTransportError:
|
|
151
|
+
return replace(row, status="sent")
|
|
152
|
+
except DeepdockError as e:
|
|
153
|
+
if e.code in RETRY_SAME_KEY:
|
|
154
|
+
return replace(row, status="sent", request_id=e.request_id)
|
|
155
|
+
if e.code == "idempotency_conflict":
|
|
156
|
+
if row.conflict_retries < CONFLICT_RETRIES:
|
|
157
|
+
return replace(row, status="sent", conflict_retries=row.conflict_retries + 1)
|
|
158
|
+
return replace(row, status="unknown")
|
|
159
|
+
if e.code == "outcome_unknown":
|
|
160
|
+
return replace(row, status="unknown", request_id=e.request_id)
|
|
161
|
+
# invalid_input, target_rejected, action-specific 422s, not_found, check_failed, auth and
|
|
162
|
+
# resolution codes: nothing was written. Fix the cause; a changed input gets a new key.
|
|
163
|
+
return replace(row, status="refused", refused_code=e.code, result={"message": str(e)})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Deepdock /v1 client. No dependencies beyond fetch (Node 18+, Bun, Deno, browsers).
|
|
3
|
+
*
|
|
4
|
+
* It does the four things the OpenAPI document cannot tell you: the bearer header, the envelope
|
|
5
|
+
* unwrap, the Idempotency-Key header with replay detection, and a typed error carrying the code.
|
|
6
|
+
* Everything action-specific (paths, fields, methods) comes from the live OpenAPI document.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class DeepdockError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
readonly status: number,
|
|
12
|
+
readonly code: string,
|
|
13
|
+
message: string,
|
|
14
|
+
readonly requestId?: string,
|
|
15
|
+
readonly systems?: string[],
|
|
16
|
+
) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'DeepdockError'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** No answer, or not Deepdock's envelope (timeout, network, a proxy page): a write may still have landed. */
|
|
23
|
+
export class DeepdockTransportError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'DeepdockTransportError'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ErrorBody = { code: string; message: string; requestId?: string; systems?: string[] }
|
|
31
|
+
type Envelope<T> = { data: T } | { error: ErrorBody }
|
|
32
|
+
|
|
33
|
+
export type Page<Item> = { items: Item[]; nextCursor: string | null }
|
|
34
|
+
export type WriteResult<T> = { data: T; replayed: boolean }
|
|
35
|
+
export type WriteMethod = 'POST' | 'PATCH'
|
|
36
|
+
|
|
37
|
+
export type Device = {
|
|
38
|
+
id: string
|
|
39
|
+
label: string | null
|
|
40
|
+
hostname: string
|
|
41
|
+
os: string
|
|
42
|
+
clientVersion: string
|
|
43
|
+
status: 'connected' | 'offline' | 'inactive'
|
|
44
|
+
lastSeenAt: string | null
|
|
45
|
+
claimedAt: string
|
|
46
|
+
systems: string[]
|
|
47
|
+
updateAvailable?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ActionState =
|
|
51
|
+
| { state: 'active' }
|
|
52
|
+
| { state: 'paused'; reason: string; pausedAt: string }
|
|
53
|
+
|
|
54
|
+
export type DeviceSystem = {
|
|
55
|
+
label: string | null
|
|
56
|
+
version: string
|
|
57
|
+
status: 'running' | 'stopped' | 'missing'
|
|
58
|
+
actions: Record<string, ActionState>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DeepdockOptions = {
|
|
62
|
+
base?: string
|
|
63
|
+
/** Above Deepdock's 20 s device wait, so you receive device_offline instead of timing out first. */
|
|
64
|
+
timeoutMs?: number
|
|
65
|
+
fetch?: typeof fetch
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Deepdock {
|
|
69
|
+
private readonly base: string
|
|
70
|
+
private readonly timeoutMs: number
|
|
71
|
+
private readonly fetchImpl: typeof fetch
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly apiKey: string,
|
|
75
|
+
{ base = 'https://api.deepdock.ai', timeoutMs = 30_000, fetch: fetchImpl = fetch }: DeepdockOptions = {},
|
|
76
|
+
) {
|
|
77
|
+
this.base = base.replace(/\/$/, '')
|
|
78
|
+
this.timeoutMs = timeoutMs
|
|
79
|
+
this.fetchImpl = fetchImpl
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async send<T>(
|
|
83
|
+
path: string,
|
|
84
|
+
init: { method: string; headers?: Record<string, string>; body?: string },
|
|
85
|
+
): Promise<{ data: T; headers: Headers }> {
|
|
86
|
+
let res: Response
|
|
87
|
+
try {
|
|
88
|
+
res = await this.fetchImpl(`${this.base}${path}`, {
|
|
89
|
+
method: init.method,
|
|
90
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, ...init.headers },
|
|
91
|
+
body: init.body,
|
|
92
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
93
|
+
})
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// TimeoutError from AbortSignal.timeout, AbortError, or a network TypeError.
|
|
96
|
+
throw new DeepdockTransportError(`no answer from Deepdock: ${(err as Error).name}`)
|
|
97
|
+
}
|
|
98
|
+
let body: Envelope<T>
|
|
99
|
+
try {
|
|
100
|
+
body = (await res.json()) as Envelope<T>
|
|
101
|
+
} catch {
|
|
102
|
+
throw new DeepdockTransportError(`non-JSON answer with status ${res.status}`)
|
|
103
|
+
}
|
|
104
|
+
if (body !== null && typeof body === 'object' && 'error' in body) {
|
|
105
|
+
const { code, message, requestId, systems } = body.error
|
|
106
|
+
throw new DeepdockError(res.status, code, message, requestId, systems)
|
|
107
|
+
}
|
|
108
|
+
if (body === null || typeof body !== 'object' || !('data' in body)) {
|
|
109
|
+
throw new DeepdockTransportError(`unexpected body with status ${res.status}`)
|
|
110
|
+
}
|
|
111
|
+
return { data: body.data, headers: res.headers }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private actionPath(deviceId: string, system: string, action: string): string {
|
|
115
|
+
return `/v1/devices/${encodeURIComponent(deviceId)}/systems/${encodeURIComponent(system)}/actions/${encodeURIComponent(action)}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Runs a read action. Pass `nextCursor` back as `cursor` for the next page. */
|
|
119
|
+
async read<Item>(
|
|
120
|
+
deviceId: string,
|
|
121
|
+
system: string,
|
|
122
|
+
action: string,
|
|
123
|
+
input: Record<string, string>,
|
|
124
|
+
page: { limit?: number; cursor?: string } = {},
|
|
125
|
+
): Promise<Page<Item>> {
|
|
126
|
+
const query = new URLSearchParams(input)
|
|
127
|
+
if (page.limit !== undefined) query.set('limit', String(page.limit))
|
|
128
|
+
if (page.cursor !== undefined) query.set('cursor', page.cursor)
|
|
129
|
+
const suffix = query.size > 0 ? `?${query}` : ''
|
|
130
|
+
return (await this.send<Page<Item>>(`${this.actionPath(deviceId, system, action)}${suffix}`, { method: 'GET' })).data
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reads every page of a read action, in order. */
|
|
134
|
+
async *readAll<Item>(
|
|
135
|
+
deviceId: string,
|
|
136
|
+
system: string,
|
|
137
|
+
action: string,
|
|
138
|
+
input: Record<string, string>,
|
|
139
|
+
limit?: number,
|
|
140
|
+
): AsyncGenerator<Item> {
|
|
141
|
+
let cursor: string | undefined
|
|
142
|
+
do {
|
|
143
|
+
const page = await this.read<Item>(deviceId, system, action, input, { limit, cursor })
|
|
144
|
+
for (const item of page.items) yield item
|
|
145
|
+
cursor = page.nextCursor ?? undefined
|
|
146
|
+
} while (cursor !== undefined)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Runs a write action. `method` comes from the action index or the OpenAPI document; `input`
|
|
151
|
+
* values are all strings. `idempotencyKey` is new per intended write and the same on every retry.
|
|
152
|
+
*/
|
|
153
|
+
async write<Result>(
|
|
154
|
+
deviceId: string,
|
|
155
|
+
system: string,
|
|
156
|
+
action: string,
|
|
157
|
+
method: WriteMethod,
|
|
158
|
+
idempotencyKey: string,
|
|
159
|
+
input: Record<string, string>,
|
|
160
|
+
): Promise<WriteResult<Result>> {
|
|
161
|
+
const { data, headers } = await this.send<Result>(this.actionPath(deviceId, system, action), {
|
|
162
|
+
method,
|
|
163
|
+
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
|
|
164
|
+
body: JSON.stringify(input),
|
|
165
|
+
})
|
|
166
|
+
return { data, replayed: headers.get('Idempotent-Replayed') === 'true' }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The device's systems and each action's state. A live call to the device: can answer device_offline. */
|
|
170
|
+
async systems(deviceId: string): Promise<Record<string, DeviceSystem>> {
|
|
171
|
+
return (await this.send<Record<string, DeviceSystem>>(`/v1/devices/${encodeURIComponent(deviceId)}/systems`, { method: 'GET' })).data
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Claims a device by the pairing code it shows. Keep the returned `id`. */
|
|
175
|
+
async pair(code: string, label?: string): Promise<Device> {
|
|
176
|
+
return (
|
|
177
|
+
await this.send<Device>('/v1/devices', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
180
|
+
body: JSON.stringify(label === undefined ? { code } : { code, label }),
|
|
181
|
+
})
|
|
182
|
+
).data
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async release(deviceId: string): Promise<void> {
|
|
186
|
+
await this.send<unknown>(`/v1/devices/${encodeURIComponent(deviceId)}`, { method: 'DELETE' })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------------------------------------
|
|
191
|
+
* Pending writes: one attempt against persisted state. Persist the row BEFORE the first send, so a
|
|
192
|
+
* crash between send and answer cannot produce a second record under a new key. A job retries rows
|
|
193
|
+
* left in `sent` with backoff (seconds, then minutes, for hours if the device stays offline).
|
|
194
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
195
|
+
|
|
196
|
+
export type PendingStatus = 'pending' | 'sent' | 'done' | 'unknown' | 'refused'
|
|
197
|
+
|
|
198
|
+
export type PendingWrite = {
|
|
199
|
+
/** 8 to 128 chars of A-Z a-z 0-9 _ -, stable per business event. */
|
|
200
|
+
key: string
|
|
201
|
+
deviceId: string
|
|
202
|
+
system: string
|
|
203
|
+
action: string
|
|
204
|
+
method: WriteMethod
|
|
205
|
+
input: Record<string, string>
|
|
206
|
+
status: PendingStatus
|
|
207
|
+
conflictRetries: number
|
|
208
|
+
result?: unknown
|
|
209
|
+
requestId?: string
|
|
210
|
+
refusedCode?: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A running write finishes within seconds; beyond this, treat a conflict as an unknown outcome. */
|
|
214
|
+
export const CONFLICT_RETRIES = 5
|
|
215
|
+
|
|
216
|
+
const RETRY_SAME_KEY = new Set([
|
|
217
|
+
'device_offline',
|
|
218
|
+
'target_busy',
|
|
219
|
+
'temporarily_unavailable',
|
|
220
|
+
'internal_error',
|
|
221
|
+
'action_paused',
|
|
222
|
+
'system_unavailable',
|
|
223
|
+
])
|
|
224
|
+
|
|
225
|
+
/** One attempt; returns the row with its new status. The caller persists it. */
|
|
226
|
+
export const attempt = async (dd: Deepdock, row: PendingWrite): Promise<PendingWrite> => {
|
|
227
|
+
try {
|
|
228
|
+
const { data } = await dd.write(row.deviceId, row.system, row.action, row.method, row.key, row.input)
|
|
229
|
+
return { ...row, status: 'done', result: data }
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err instanceof DeepdockTransportError) return { ...row, status: 'sent' }
|
|
232
|
+
if (!(err instanceof DeepdockError)) throw err
|
|
233
|
+
if (RETRY_SAME_KEY.has(err.code)) return { ...row, status: 'sent', requestId: err.requestId }
|
|
234
|
+
if (err.code === 'idempotency_conflict') {
|
|
235
|
+
return row.conflictRetries < CONFLICT_RETRIES
|
|
236
|
+
? { ...row, status: 'sent', conflictRetries: row.conflictRetries + 1 }
|
|
237
|
+
: { ...row, status: 'unknown' }
|
|
238
|
+
}
|
|
239
|
+
if (err.code === 'outcome_unknown') return { ...row, status: 'unknown', requestId: err.requestId }
|
|
240
|
+
// invalid_input, target_rejected, action-specific 422s, not_found, check_failed, auth and
|
|
241
|
+
// resolution codes: nothing was written. Fix the cause; a changed input gets a new key.
|
|
242
|
+
return { ...row, status: 'refused', refusedCode: err.code, result: { message: err.message } }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Concepts
|
|
2
|
+
|
|
3
|
+
The words the API, the CLI and the dashboard use. Use them unchanged in code and copy.
|
|
4
|
+
|
|
5
|
+
## Organisation and partner
|
|
6
|
+
|
|
7
|
+
You, the partner, are an **organisation** in Deepdock. Everything you own (keys, devices,
|
|
8
|
+
incidents) is scoped to it. Members of your organisation see the dashboard; admins manage keys,
|
|
9
|
+
release devices.
|
|
10
|
+
|
|
11
|
+
## Keys
|
|
12
|
+
|
|
13
|
+
- **API key** (`ddk_live_` on production; a `ddk_test_` key belongs to a test deployment): your product's
|
|
14
|
+
credential on `/v1`. It runs actions, pairs and releases devices, and reads incidents. Created by an
|
|
15
|
+
admin in the dashboard (shown once) or from the CLI, which writes it straight into the app's env
|
|
16
|
+
file, keeps no copy and shows it to nobody; optionally expiring.
|
|
17
|
+
- **CLI sign-in**: one person's credential, used only by the `deepdock` CLI after `deepdock auth
|
|
18
|
+
login`. Valid 90 days. It manages (devices, keys, listing systems) and never runs an action.
|
|
19
|
+
|
|
20
|
+
Details: [auth-and-keys.md](auth-and-keys.md).
|
|
21
|
+
|
|
22
|
+
## Device
|
|
23
|
+
|
|
24
|
+
The Deepdock client installed on the customer's Windows PC next to their system. It connects
|
|
25
|
+
outwards to Deepdock; nothing in the customer's network is opened to the internet. A device is
|
|
26
|
+
identified by a UUID (`id`), carries a `label`, `hostname`, `os`, `clientVersion`, `status`
|
|
27
|
+
(`connected`, `offline` or `inactive`), `lastSeenAt`, `claimedAt` and the `systems` it reported.
|
|
28
|
+
|
|
29
|
+
- **Pair**: claim a device for your organisation with the 6-character code it shows while unpaired.
|
|
30
|
+
- **Release**: `DELETE /v1/devices/{deviceId}` gives the device back; it shows a new code.
|
|
31
|
+
- **Inactive**: a paired device that has not connected for 7 days. It stays yours and is active again
|
|
32
|
+
when it connects.
|
|
33
|
+
- **Update**: the client updates itself. A device on an older build than Deepdock ships reports
|
|
34
|
+
`updateAvailable`.
|
|
35
|
+
|
|
36
|
+
## System
|
|
37
|
+
|
|
38
|
+
A program on the device that Deepdock has made reachable, named by a slug such as `example-erp`. Each
|
|
39
|
+
has a display `label` (may be `null`), a `version` the device reported, a `status`, and actions.
|
|
40
|
+
`GET /v1/devices/{deviceId}/systems` is a live call to the device, so it can answer
|
|
41
|
+
`device_offline`.
|
|
42
|
+
|
|
43
|
+
### Every status value and what it means for you
|
|
44
|
+
|
|
45
|
+
| Field | Value | Meaning | What to do |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| device `status` | `connected` | the device is online and can run actions | normal |
|
|
48
|
+
| device `status` | `offline` | the device is not connected right now (PC off, no network) | calls answer `device_offline`; retry later, show "offline" |
|
|
49
|
+
| device `status` | `inactive` | not connected for 7 days; still paired | as offline; check with the site whether the PC still exists |
|
|
50
|
+
| system `status` | `running` | the customer's program is running on the device | normal |
|
|
51
|
+
| system `status` | `stopped` | the program is installed but not running | calls answer `system_unavailable`; the site starts the program |
|
|
52
|
+
| system `status` | `missing` | the program was not found where the device expects it | calls answer `system_unavailable`; contact the site and Deepdock |
|
|
53
|
+
| system `status` | `unknown` | the device could not tell whether the program runs (its probe failed or is unavailable on that PC) | do not read it as stopped; the device decides when a call arrives |
|
|
54
|
+
| system `access` | `ok`, `outdated`, `missing`, `not_set_up`, or `null` | the database access the device last reported (`null`: none known yet) | `outdated` refuses the actions that need the missing right with `access_outdated`, `missing` refuses every action with `access_missing`; the practice repeats "Erneut einrichten" in the Deepdock client, the incident closes by itself once `access` is `ok`, nothing to resume |
|
|
55
|
+
| action state | `active` | the action can be called | normal |
|
|
56
|
+
| action state | `paused` | the action is paused with a `reason` and `pausedAt` | calls answer `action_paused`; show the feature as paused, poll `GET .../systems` at a low rate |
|
|
57
|
+
| pause `reason` | `structure_changed` | the system changed in a way the action depends on (for example a vendor update); Deepdock reviews and resumes | wait; nothing to do on your side |
|
|
58
|
+
| pause `reason` | `check_failed` | a write's result did not match, so nothing was saved | wait; tell the user the change was not saved |
|
|
59
|
+
| pause `reason` | `system_stopped`, `system_missing` | as the system statuses above; these need no resume and clear when the system is back | as above |
|
|
60
|
+
|
|
61
|
+
Switch on these values; any value you do not know is shown as it is.
|
|
62
|
+
|
|
63
|
+
## Action
|
|
64
|
+
|
|
65
|
+
One deterministic, validated operation on a system: for example search customers by surname prefix,
|
|
66
|
+
create a note, update a note's text. Deepdock builds and verifies each action before
|
|
67
|
+
offering it; it then runs the same way every time. Actions are
|
|
68
|
+
**assigned** to your organisation by Deepdock, so the set you can call is yours, not the whole
|
|
69
|
+
catalogue.
|
|
70
|
+
|
|
71
|
+
- A **read** is `GET` with query parameters and pages.
|
|
72
|
+
- A **write** is `POST` (creates a record, answers `201`) or `PATCH` (updates a record, answers
|
|
73
|
+
`200`), with a JSON body of string fields and an `Idempotency-Key` header.
|
|
74
|
+
- A write **either fully applies or not**: Deepdock checks the result before confirming, and if the check fails it saves nothing
|
|
75
|
+
and pauses the action (`409 check_failed`).
|
|
76
|
+
- An action is **active** or **paused**. `GET /v1/devices/{deviceId}/systems` shows each action's
|
|
77
|
+
state with `reason` and `pausedAt`.
|
|
78
|
+
|
|
79
|
+
## Pause and incident
|
|
80
|
+
|
|
81
|
+
Deepdock pauses an action instead of guessing when the customer's system no longer looks the way
|
|
82
|
+
the action expects:
|
|
83
|
+
|
|
84
|
+
| Reason | What you get | Meaning |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `structure_changed` | `503 action_paused` | the system changed (for example a vendor update); Deepdock reviews and resumes |
|
|
87
|
+
| `check_failed` | that call `409 check_failed`, later calls `503 action_paused` | a write's result did not match, so nothing was saved |
|
|
88
|
+
| `system_stopped`, `system_missing` | `503 system_unavailable` | the customer's program is stopped or not found on the device |
|
|
89
|
+
|
|
90
|
+
Each pause opens an **incident** (`GET /v1/incidents`). Only Deepdock lifts a pause; staff at the
|
|
91
|
+
customer's site see that an action is paused but cannot resume it; there is no partner call to
|
|
92
|
+
resume. A stopped or missing system needs no resume: its incident closes once the system is back.
|
|
93
|
+
|
|
94
|
+
An incident has `id`, `system`, `version`, `action` (`null` for a system-level incident), `reason`,
|
|
95
|
+
`openedAt`, `closedAt`, and `devices` with `deviceId`, `pausedAt`, `resumedAt` and `inactiveAt`. It
|
|
96
|
+
closes once none of its devices is still paused. `GET /v1/incidents` lists incidents that are open
|
|
97
|
+
or closed within the last 30 days, newest first, at most 100.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Discovering systems and actions
|
|
2
|
+
|
|
3
|
+
Never hard-code an action's fields. Three sources, each authoritative for one thing:
|
|
4
|
+
|
|
5
|
+
| Question | Source | Key |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Which systems and actions does my organisation have? | `deepdock systems list --json` | CLI sign-in |
|
|
8
|
+
| What does this device run, and is each action active or paused? | `GET /v1/devices/{deviceId}/systems` | organisation |
|
|
9
|
+
| Which actions can I call on this device's system, with which schemas? | `GET /v1/devices/{deviceId}/systems/{system}/actions` | organisation |
|
|
10
|
+
| The exact paths, methods, fields, patterns, enums, lengths, responses | `deepdock systems openapi <system>` (CLI sign-in) or `GET /v1/systems/{system}/openapi.json` (API key); per device `deepdock actions list <device> <system>` (CLI sign-in) | CLI sign-in or API key |
|
|
11
|
+
|
|
12
|
+
## The OpenAPI document
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
deepdock systems openapi example-erp --org <slug> > openapi/example-erp.json
|
|
16
|
+
# from your backend, with the API key:
|
|
17
|
+
curl "$BASE/v1/systems/example-erp/openapi.json" -H "Authorization: Bearer $DEEPDOCK_API_KEY"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
OpenAPI 3.1, one document per system, cacheable privately for 60 seconds. It is never public:
|
|
21
|
+
`GET /v1/systems/{system}/openapi.json` needs your API key like the rest of `/v1`
|
|
22
|
+
(`401 unauthenticated` without one), and the CLI fetches it with its sign-in instead. Discover through the CLI; check the
|
|
23
|
+
document into the repository and refresh it with the same command when you touch the integration.
|
|
24
|
+
|
|
25
|
+
What to read off it:
|
|
26
|
+
|
|
27
|
+
- **`paths`**: one operation per action at
|
|
28
|
+
`/v1/devices/{deviceId}/systems/{system}/actions/{action}`; the HTTP method is the action's
|
|
29
|
+
kind (`get` read, `post` create, `patch` update). Use the method the document gives; the other
|
|
30
|
+
write method answers `405 invalid_request` with an `Allow` header.
|
|
31
|
+
- **`summary` and `description`**: what the action does and what it changes in the system, what
|
|
32
|
+
an omitted optional input becomes, action-specific refusals (`code` and when), and what the
|
|
33
|
+
system only warns about and what the action does then. Read the description before mapping your
|
|
34
|
+
fields.
|
|
35
|
+
- **Read inputs** are query parameters, all `type: string`, each with `maxLength` and possibly
|
|
36
|
+
`minLength`, `pattern`, `enum` and a `description` such as "prefix match: case, punctuation and
|
|
37
|
+
umlaut spelling are ignored". Plus `limit` (integer, 1 to 250, default 25) and `cursor` (opaque).
|
|
38
|
+
An optional parameter is not in `required` and its description says what an omitted value
|
|
39
|
+
becomes.
|
|
40
|
+
- **Write inputs** are the `requestBody` object schema: `additionalProperties: false`, every
|
|
41
|
+
property `type: string`, `required` lists the mandatory ones. The `Idempotency-Key` header
|
|
42
|
+
parameter carries its own pattern (8 to 128 characters of `A-Z a-z 0-9 _ -`).
|
|
43
|
+
- **Responses**: a read's `200` is `{ data: { items: [...], nextCursor } }`; a create's `201` and a
|
|
44
|
+
replayed `200`; an update's `200`. Item and result fields are typed `string`, `integer` or `number`,
|
|
45
|
+
often nullable (`["integer", "null"]`). The `Error` schema's `code` enum is the full list of codes.
|
|
46
|
+
- **`servers`**: the base URL of the API.
|
|
47
|
+
|
|
48
|
+
The document describes **Deepdock's whole catalogue** for the system. Your organisation may be
|
|
49
|
+
assigned a subset. Write code only for actions that appear in both the document and `systems list`
|
|
50
|
+
(or the device's action index); an action in the document but not assigned to you answers
|
|
51
|
+
`404 action_not_found`.
|
|
52
|
+
|
|
53
|
+
## The action index
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl "$BASE/v1/devices/$DEVICE/systems/example-erp/actions" \
|
|
57
|
+
-H "Authorization: Bearer $DEEPDOCK_API_KEY"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Answers `{ data: [ { name, method, description, input, output } ] }`, filtered to your assigned
|
|
61
|
+
actions, with the same JSON Schemas the OpenAPI document carries. Use it at runtime to confirm an
|
|
62
|
+
action exists before offering it in your UI, or to validate input client-side before sending.
|
|
63
|
+
|
|
64
|
+
## The device's systems
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
curl "$BASE/v1/devices/$DEVICE/systems" -H "Authorization: Bearer $DEEPDOCK_API_KEY"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A live call to the device. Each system: `label`, `version`, `status`, and per action `active` or
|
|
71
|
+
`paused` with `reason` and `pausedAt`. Answers `503 device_offline` when the device is not
|
|
72
|
+
connected: that is a normal state, not an error in your code. Do not poll it tightly; it reaches the
|
|
73
|
+
customer's PC.
|
|
74
|
+
|
|
75
|
+
## Generated clients
|
|
76
|
+
|
|
77
|
+
If the project already uses an OpenAPI generator (`openapi-typescript`, `openapi-generator`,
|
|
78
|
+
`kiota`), generating from the document is fine. The generator will not produce the envelope unwrap,
|
|
79
|
+
the idempotency rule or the error-code reactions; those come from this skill. Remember the
|
|
80
|
+
document is the whole catalogue, not your assigned set.
|