@arbidocs/cli 0.3.46 → 0.3.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbidocs/cli",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "CLI tool for interacting with ARBI — login, manage workspaces, upload documents, query the RAG assistant",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,9 +21,9 @@
21
21
  "preuninstall": "node scripts/preuninstall.js"
22
22
  },
23
23
  "dependencies": {
24
- "@arbidocs/sdk": "0.3.46",
25
- "@arbidocs/client": "0.3.46",
26
- "@arbidocs/tui": "0.3.46",
24
+ "@arbidocs/sdk": "0.3.48",
25
+ "@arbidocs/client": "0.3.48",
26
+ "@arbidocs/tui": "0.3.48",
27
27
  "@inquirer/prompts": "^8.2.0",
28
28
  "chalk": "^5.6.2",
29
29
  "commander": "^13.1.0"
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * time-workspace-open.mjs
4
+ *
5
+ * Times the POST /v1/workspace/{id}/open call with a detailed breakdown:
6
+ *
7
+ * - setup — auth + crypto (workspace key unseal/seal). Excluded from "open" time.
8
+ * - ttfb — POST request start → first byte. ≈ network RTT + backend processing
9
+ * (DB query, decrypt, serialize JSON, begin streaming).
10
+ * - download — first byte → last byte. ≈ wire transfer time for the payload.
11
+ * - parse — JSON.parse on the raw body.
12
+ * - total — ttfb + download + parse.
13
+ *
14
+ * Also reports:
15
+ * - bytes — raw response body size
16
+ * - docs — documents[] count (if present)
17
+ * - per-doc — bytes / docs
18
+ *
19
+ * Usage:
20
+ * node packages/arbi-cli/scripts/time-workspace-open.mjs # uses selected workspace
21
+ * node packages/arbi-cli/scripts/time-workspace-open.mjs wrk-820e4c6d
22
+ * node packages/arbi-cli/scripts/time-workspace-open.mjs wrk-820e4c6d --runs=3
23
+ *
24
+ * Requires:
25
+ * - `arbi login` has been run with a valid cached session
26
+ * - the SDK is built (it's an internal workspace dep so fresh-from-source works too)
27
+ */
28
+
29
+ // Stub IndexedDB + structuredClone before anything else — @arbidocs/client's login
30
+ // flow calls saveSession() which writes to IndexedDB; in Node we use fake-indexeddb.
31
+ import 'fake-indexeddb/auto'
32
+
33
+ import { performance } from 'node:perf_hooks'
34
+ import { readFileSync } from 'node:fs'
35
+ import { homedir } from 'node:os'
36
+ import path from 'node:path'
37
+
38
+ // ─── arg parsing ─────────────────────────────────────────────────────────
39
+ const rawArgs = process.argv.slice(2)
40
+ const positional = []
41
+ const flags = {}
42
+ for (const raw of rawArgs) {
43
+ if (raw.startsWith('--')) {
44
+ const m = raw.match(/^--([^=]+)(?:=(.*))?$/)
45
+ if (m) flags[m[1]] = m[2] ?? 'true'
46
+ } else {
47
+ positional.push(raw)
48
+ }
49
+ }
50
+ const RUNS = Number(flags.runs ?? 1)
51
+ const WORKSPACE_ARG = positional[0]
52
+
53
+ // ─── load CLI config/credentials from ~/.arbi ────────────────────────────
54
+ const arbiDir = path.join(homedir(), '.arbi')
55
+ const config = JSON.parse(readFileSync(path.join(arbiDir, 'config.json'), 'utf8'))
56
+ const credentials = JSON.parse(readFileSync(path.join(arbiDir, 'credentials.json'), 'utf8'))
57
+
58
+ const workspaceId = WORKSPACE_ARG ?? config.selectedWorkspaceId
59
+ if (!workspaceId) {
60
+ console.error('No workspace ID given and none selected.')
61
+ process.exit(1)
62
+ }
63
+
64
+ console.log(`server: ${config.baseUrl}`)
65
+ console.log(`workspace: ${workspaceId}`)
66
+ console.log(`runs: ${RUNS}`)
67
+ console.log('')
68
+
69
+ // ─── import SDK + client ─────────────────────────────────────────────────
70
+ const { createArbiClient, base64ToBytes } = await import('@arbidocs/client')
71
+ const { generateEncryptedWorkspaceKey } = await import('@arbidocs/sdk')
72
+
73
+ // ─── per-run timing ──────────────────────────────────────────────────────
74
+ async function timeOneRun() {
75
+ const t0 = performance.now()
76
+
77
+ // ── setup: fresh arbi client + login to get a session ──────────────────
78
+ // Minimal in-memory sessionStorage shim so @arbidocs/client doesn't fall back
79
+ // to IndexedDB (which doesn't exist in Node). Only used by the auto-relogin
80
+ // middleware — we never actually trigger it since we log in fresh here.
81
+ const memorySessionStorage = {
82
+ async getSession() {
83
+ return {
84
+ signingPrivateKey: base64ToBytes(credentials.signingPrivateKeyBase64),
85
+ serverSessionKey: credentials.serverSessionKeyBase64
86
+ ? base64ToBytes(credentials.serverSessionKeyBase64)
87
+ : undefined,
88
+ }
89
+ },
90
+ async saveSession() {},
91
+ async clearSession() {},
92
+ }
93
+
94
+ const arbi = createArbiClient({
95
+ baseUrl: config.baseUrl,
96
+ deploymentDomain: config.deploymentDomain,
97
+ credentials: 'omit',
98
+ sessionStorage: memorySessionStorage,
99
+ })
100
+ await arbi.crypto.initSodium()
101
+
102
+ const signingPrivateKey = base64ToBytes(credentials.signingPrivateKeyBase64)
103
+ const loginResult = await arbi.auth.loginWithKey({
104
+ email: credentials.email,
105
+ signingPrivateKey,
106
+ ssoToken: credentials.ssoToken,
107
+ })
108
+
109
+ // Fetch workspace list to get the wrapped key
110
+ const { data: workspaces, error: wsError } = await arbi.fetch.GET('/v1/user/workspaces')
111
+ if (wsError || !workspaces) throw new Error('Failed to fetch workspaces')
112
+ const ws = workspaces.find((w) => w.external_id === workspaceId)
113
+ if (!ws) throw new Error(`workspace ${workspaceId} not found`)
114
+ if (!ws.wrapped_key) throw new Error(`workspace ${workspaceId} has no wrapped_key`)
115
+
116
+ const encryptedWorkspaceKey = await generateEncryptedWorkspaceKey(
117
+ arbi,
118
+ ws.wrapped_key,
119
+ loginResult.serverSessionKey,
120
+ credentials.signingPrivateKeyBase64
121
+ )
122
+
123
+ const setupMs = performance.now() - t0
124
+ const docCountHint = (ws.shared_document_count ?? 0) + (ws.private_document_count ?? 0)
125
+
126
+ // ── the actual /open POST with streaming fetch for TTFB measurement ────
127
+ const qs = flags['no-documents'] ? '?include_documents=false' : ''
128
+ const url = `${config.baseUrl}/v1/workspace/${workspaceId}/open${qs}`
129
+ const requestBody = JSON.stringify({ workspace_key: encryptedWorkspaceKey })
130
+
131
+ const tPostStart = performance.now()
132
+ const res = await fetch(url, {
133
+ method: 'POST',
134
+ headers: {
135
+ 'content-type': 'application/json',
136
+ authorization: `Bearer ${loginResult.accessToken}`,
137
+ },
138
+ body: requestBody,
139
+ })
140
+ const tHeaders = performance.now() // headers received ≈ TTFB
141
+
142
+ if (!res.ok) {
143
+ const text = await res.text()
144
+ throw new Error(`POST /open failed ${res.status}: ${text.slice(0, 500)}`)
145
+ }
146
+
147
+ // Stream the body to measure download time separately from TTFB
148
+ const reader = res.body.getReader()
149
+ const chunks = []
150
+ let bytes = 0
151
+ // eslint-disable-next-line no-constant-condition
152
+ while (true) {
153
+ const { done, value } = await reader.read()
154
+ if (done) break
155
+ chunks.push(value)
156
+ bytes += value.byteLength
157
+ }
158
+ const tDownload = performance.now()
159
+
160
+ // Assemble + parse
161
+ const body = new Uint8Array(bytes)
162
+ let offset = 0
163
+ for (const chunk of chunks) {
164
+ body.set(chunk, offset)
165
+ offset += chunk.byteLength
166
+ }
167
+ const text = new TextDecoder().decode(body)
168
+ const json = JSON.parse(text)
169
+ const tParse = performance.now()
170
+
171
+ const ttfbMs = tHeaders - tPostStart
172
+ const downloadMs = tDownload - tHeaders
173
+ const parseMs = tParse - tDownload
174
+ const totalMs = tParse - tPostStart
175
+
176
+ const docs = Array.isArray(json.documents) ? json.documents.length : 0
177
+ const conversations = Array.isArray(json.conversations) ? json.conversations.length : 0
178
+ const tags = Array.isArray(json.tags) ? json.tags.length : 0
179
+
180
+ return {
181
+ setupMs,
182
+ ttfbMs,
183
+ downloadMs,
184
+ parseMs,
185
+ totalMs,
186
+ bytes,
187
+ docs,
188
+ conversations,
189
+ tags,
190
+ docCountHint,
191
+ }
192
+ }
193
+
194
+ function fmt(ms) {
195
+ return `${ms.toFixed(1).padStart(8)} ms`
196
+ }
197
+ function fmtBytes(n) {
198
+ if (n < 1024) return `${n} B`
199
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
200
+ return `${(n / 1024 / 1024).toFixed(2)} MB`
201
+ }
202
+
203
+ // ─── run it ──────────────────────────────────────────────────────────────
204
+ const results = []
205
+ for (let i = 1; i <= RUNS; i++) {
206
+ process.stdout.write(`run ${i}/${RUNS} ... `)
207
+ try {
208
+ const r = await timeOneRun()
209
+ results.push(r)
210
+ console.log(
211
+ `total=${r.totalMs.toFixed(0)}ms ttfb=${r.ttfbMs.toFixed(0)}ms dl=${r.downloadMs.toFixed(0)}ms parse=${r.parseMs.toFixed(0)}ms size=${fmtBytes(r.bytes)} docs=${r.docs}`
212
+ )
213
+ } catch (err) {
214
+ console.log(`FAILED: ${err.message}`)
215
+ process.exit(1)
216
+ }
217
+ }
218
+
219
+ // ─── summary ─────────────────────────────────────────────────────────────
220
+ console.log('')
221
+ console.log('─── summary ──────────────────────────────────────────')
222
+ const avg = (key) => results.reduce((s, r) => s + r[key], 0) / results.length
223
+ const last = results[results.length - 1]
224
+
225
+ console.log(`setup ${fmt(avg('setupMs'))} (auth + key prep, excluded from /open total)`)
226
+ console.log(`ttfb ${fmt(avg('ttfbMs'))} (network RTT + backend: DB + decrypt + serialize)`)
227
+ console.log(`download ${fmt(avg('downloadMs'))} (wire transfer of JSON body)`)
228
+ console.log(`parse ${fmt(avg('parseMs'))} (JSON.parse on client)`)
229
+ console.log(`─────────────────────────────────────`)
230
+ console.log(`total ${fmt(avg('totalMs'))} (ttfb + download + parse)`)
231
+ console.log('')
232
+ console.log(`payload ${fmtBytes(last.bytes)} (${last.bytes.toLocaleString()} bytes)`)
233
+ console.log(`documents ${last.docs}`)
234
+ console.log(`conversat. ${last.conversations}`)
235
+ console.log(`tags ${last.tags}`)
236
+ if (last.docs > 0) {
237
+ const perDoc = last.bytes / last.docs
238
+ console.log(`per-doc ${fmtBytes(perDoc)} ${perDoc.toFixed(0)} B/doc`)
239
+ console.log('')
240
+ console.log('─── extrapolation ────────────────────────────────────')
241
+ for (const n of [500, 2000, 10000, 40000]) {
242
+ const projBytes = perDoc * n
243
+ console.log(` ${String(n).padStart(6)} docs → ${fmtBytes(projBytes).padStart(10)}`)
244
+ }
245
+ }