@frontera-sdk/cli 0.1.0 → 1.43.6
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 +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { PlatformApi } from '../../api/platform-api'
|
|
2
2
|
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { table } from '../../table'
|
|
4
|
-
import
|
|
4
|
+
import { resolveAgentRef } from '../agent/resolve'
|
|
5
|
+
import { flagString, type Command } from '../types'
|
|
6
|
+
import { renderUploadSummary, summarizeUploads, uploadAll } from './upload-batch'
|
|
7
|
+
import { planUploads } from './upload-plan'
|
|
5
8
|
|
|
6
9
|
interface KnowledgeRow {
|
|
7
10
|
id?: string
|
|
@@ -10,19 +13,25 @@ interface KnowledgeRow {
|
|
|
10
13
|
description?: string
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
async function
|
|
15
|
-
const
|
|
16
|
-
if (UUID.test(ref)) return ref
|
|
17
|
-
|
|
18
|
-
const me = await client.whoami()
|
|
16
|
+
/** The workspace a `sk-ws-` key is scoped to. Not visible from anywhere else. */
|
|
17
|
+
async function requireWorkspaceId(api: PlatformApi): Promise<string> {
|
|
18
|
+
const me = await api.whoami()
|
|
19
19
|
if (!me.workspaceId) {
|
|
20
20
|
throw new CliError('this credential is not scoped to a workspace', {
|
|
21
21
|
code: 'FORBIDDEN',
|
|
22
22
|
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
return me.workspaceId
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
29
|
+
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
30
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
|
+
if (UUID.test(ref)) return ref
|
|
32
|
+
|
|
33
|
+
const workspaceId = await requireWorkspaceId(client)
|
|
34
|
+
const rows = (await client.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
26
35
|
const match = rows.find((k) => (k.name ?? '').toLowerCase() === ref.toLowerCase())
|
|
27
36
|
if (match?.id) return match.id
|
|
28
37
|
|
|
@@ -45,14 +54,8 @@ const list: Command = {
|
|
|
45
54
|
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
46
55
|
// The route takes the workspace explicitly, and a key holder cannot see
|
|
47
56
|
// its own workspace id from anywhere else — hence /v1/whoami.
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
throw new CliError('this credential is not scoped to a workspace', {
|
|
51
|
-
code: 'FORBIDDEN',
|
|
52
|
-
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
53
|
-
})
|
|
54
|
-
}
|
|
55
|
-
const rows = (await api.knowledgeBases(me.workspaceId)) as KnowledgeRow[]
|
|
57
|
+
const workspaceId = await requireWorkspaceId(api)
|
|
58
|
+
const rows = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
56
59
|
return {
|
|
57
60
|
data: rows,
|
|
58
61
|
text:
|
|
@@ -108,20 +111,259 @@ const sources: Command = {
|
|
|
108
111
|
},
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
const create: Command = {
|
|
115
|
+
meta: {
|
|
116
|
+
noun: 'knowledge',
|
|
117
|
+
verb: 'create',
|
|
118
|
+
args: [{ name: 'name', required: true, description: 'base name — lowercase, the value every other knowledge command takes' }],
|
|
119
|
+
flags: { description: 'string' },
|
|
120
|
+
summary: 'Create a knowledge base',
|
|
121
|
+
examples: [
|
|
122
|
+
'frontera knowledge create logistics-policies',
|
|
123
|
+
'frontera knowledge create policies --description "SOPs and tariffs"',
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
async run(ctx) {
|
|
127
|
+
const name = ctx.positional[0]
|
|
128
|
+
if (!name) {
|
|
129
|
+
throw new UsageError('missing <name>', 'frontera knowledge create <name> [--description <text>]')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
133
|
+
const workspaceId = await requireWorkspaceId(api)
|
|
134
|
+
|
|
135
|
+
// Checked here rather than left to the unique constraint: the service
|
|
136
|
+
// answers a duplicate with a bare CONFLICT, whose generic hint ("resolve
|
|
137
|
+
// what conflicted") sends a caller looking for a race that did not happen.
|
|
138
|
+
const existing = (await api.knowledgeBases(workspaceId)) as KnowledgeRow[]
|
|
139
|
+
if (existing.some((k) => (k.name ?? '').toLowerCase() === name.toLowerCase())) {
|
|
140
|
+
throw new CliError(`a knowledge base named "${name}" already exists`, {
|
|
141
|
+
code: 'CONFLICT',
|
|
142
|
+
hint: `upload into it with \`frontera knowledge upload ${name} <path…>\``,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const description = flagString(ctx, 'description')
|
|
147
|
+
const created = (await api.createKnowledgeBase(
|
|
148
|
+
workspaceId,
|
|
149
|
+
description ? { name, description } : { name },
|
|
150
|
+
)) as { data?: KnowledgeRow } | KnowledgeRow
|
|
151
|
+
const row = ((created as { data?: KnowledgeRow }).data ?? created) as KnowledgeRow
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
data: row,
|
|
155
|
+
text: [
|
|
156
|
+
`created ${row.name ?? name}${row.id ? ` ${row.id}` : ''}`,
|
|
157
|
+
` next: frontera knowledge upload ${row.name ?? name} <path…>`,
|
|
158
|
+
].join('\n'),
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const upload: Command = {
|
|
164
|
+
meta: {
|
|
165
|
+
noun: 'knowledge',
|
|
166
|
+
verb: 'upload',
|
|
167
|
+
args: [
|
|
168
|
+
{ name: 'base', required: true, description: 'knowledge base name or id' },
|
|
169
|
+
{ name: 'path...', required: true, description: 'files or directories; directories are walked, unsupported types skipped' },
|
|
170
|
+
],
|
|
171
|
+
flags: { strategy: 'string' },
|
|
172
|
+
summary: 'Upload files into a knowledge base',
|
|
173
|
+
examples: [
|
|
174
|
+
'frontera knowledge upload logistics-policies ./policies',
|
|
175
|
+
'frontera knowledge upload logistics-policies tariffs.pdf sop.md',
|
|
176
|
+
'frontera knowledge upload logistics-policies ./scans --strategy ocr',
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
async run(ctx) {
|
|
180
|
+
const [ref, ...paths] = ctx.positional
|
|
181
|
+
if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
|
|
182
|
+
if (paths.length === 0) {
|
|
183
|
+
throw new UsageError(
|
|
184
|
+
'missing <path...>',
|
|
185
|
+
'frontera knowledge upload <base> <file-or-directory…>',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const strategy = flagString(ctx, 'strategy')
|
|
190
|
+
if (strategy && !['auto', 'text', 'ocr'].includes(strategy)) {
|
|
191
|
+
throw new UsageError(
|
|
192
|
+
`unknown --strategy "${strategy}"`,
|
|
193
|
+
'auto (default), text, or ocr — ocr needs an OCR engine configured on the base',
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const plan = planUploads(paths)
|
|
198
|
+
if (plan.rejected.length > 0) {
|
|
199
|
+
// A path the caller NAMED and cannot be uploaded is a mistake in the
|
|
200
|
+
// command, so nothing is uploaded — a batch half-run on a typo leaves the
|
|
201
|
+
// base in a state the caller did not ask for and cannot infer.
|
|
202
|
+
const first = plan.rejected[0]!
|
|
203
|
+
throw new UsageError(
|
|
204
|
+
`cannot upload ${first.path}: ${first.reason}`,
|
|
205
|
+
plan.rejected.length > 1
|
|
206
|
+
? `${plan.rejected.length} named paths are unusable — fix them, or pass a directory and let unsupported types be skipped`
|
|
207
|
+
: 'fix the path, or pass a directory and let unsupported types be skipped',
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
if (plan.files.length === 0) {
|
|
211
|
+
throw new CliError('nothing to upload', {
|
|
212
|
+
code: 'USAGE',
|
|
213
|
+
hint:
|
|
214
|
+
plan.skippedByType > 0
|
|
215
|
+
? `${plan.skippedByType} file(s) found, none of a supported type`
|
|
216
|
+
: 'the paths given contain no files',
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
221
|
+
const baseId = await resolveKnowledgeRef(api, ref)
|
|
222
|
+
const outcomes = await uploadAll(api, baseId, plan.files, strategy)
|
|
223
|
+
const summary = summarizeUploads(ref, baseId, outcomes, plan)
|
|
224
|
+
|
|
225
|
+
// Nothing landed: the mechanism is wrong, not the batch. Fail so the exit
|
|
226
|
+
// code says so, and name the first cause rather than a count.
|
|
227
|
+
if (summary.uploaded === 0) {
|
|
228
|
+
const first = outcomes.find((o) => o.status === 'failed')
|
|
229
|
+
throw new CliError(`all ${summary.failed} upload(s) failed: ${first?.error ?? 'unknown error'}`, {
|
|
230
|
+
code: 'FAILURE',
|
|
231
|
+
hint: 'fix the cause above and re-run — uploading is additive, so a retry duplicates nothing already ingested',
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { data: summary, text: renderUploadSummary(summary) }
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const attach: Command = {
|
|
240
|
+
meta: {
|
|
241
|
+
noun: 'knowledge',
|
|
242
|
+
verb: 'attach',
|
|
243
|
+
args: [
|
|
244
|
+
{ name: 'base', required: true, description: 'knowledge base name or id' },
|
|
245
|
+
{ name: 'agent', required: true, description: 'agent slug or id, from `frontera agent list`' },
|
|
246
|
+
],
|
|
247
|
+
flags: {},
|
|
248
|
+
summary: 'Give an agent access to a knowledge base',
|
|
249
|
+
examples: ['frontera knowledge attach logistics-policies ava'],
|
|
250
|
+
},
|
|
251
|
+
async run(ctx) {
|
|
252
|
+
const [baseRef, agentRef] = ctx.positional
|
|
253
|
+
if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
|
|
254
|
+
if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
|
|
255
|
+
|
|
256
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
257
|
+
const baseId = await resolveKnowledgeRef(api, baseRef)
|
|
258
|
+
const agentId = await resolveAgentRef(api, agentRef)
|
|
259
|
+
await api.attachKnowledgeAgent(baseId, agentId)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
data: { baseId, agentId, attached: true },
|
|
263
|
+
text: [
|
|
264
|
+
`attached ${baseRef} to ${agentRef}`,
|
|
265
|
+
// Attachment is what makes knowledge reachable — including from an
|
|
266
|
+
// automation, which has no knowledge capability of its own and reads a
|
|
267
|
+
// corpus only through an agent that has one attached.
|
|
268
|
+
` verify: frontera agent get ${agentRef}`,
|
|
269
|
+
].join('\n'),
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const detach: Command = {
|
|
275
|
+
meta: {
|
|
276
|
+
noun: 'knowledge',
|
|
277
|
+
verb: 'detach',
|
|
278
|
+
args: [
|
|
279
|
+
{ name: 'base', required: true, description: 'knowledge base name or id' },
|
|
280
|
+
{ name: 'agent', required: true, description: 'agent slug or id' },
|
|
281
|
+
],
|
|
282
|
+
flags: {},
|
|
283
|
+
summary: 'Take an agent’s access to a knowledge base away',
|
|
284
|
+
examples: ['frontera knowledge detach logistics-policies ava'],
|
|
285
|
+
},
|
|
286
|
+
async run(ctx) {
|
|
287
|
+
const [baseRef, agentRef] = ctx.positional
|
|
288
|
+
if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
|
|
289
|
+
if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
|
|
290
|
+
|
|
291
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
292
|
+
const baseId = await resolveKnowledgeRef(api, baseRef)
|
|
293
|
+
const agentId = await resolveAgentRef(api, agentRef)
|
|
294
|
+
await api.detachKnowledgeAgent(baseId, agentId)
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
data: { baseId, agentId, attached: false },
|
|
298
|
+
// Said explicitly because the reverse — deleting the corpus — is what a
|
|
299
|
+
// reader might assume from a word like detach.
|
|
300
|
+
text: `detached ${baseRef} from ${agentRef} — the base and its sources are unchanged`,
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const agents: Command = {
|
|
306
|
+
meta: {
|
|
307
|
+
noun: 'knowledge',
|
|
308
|
+
verb: 'agents',
|
|
309
|
+
args: [{ name: 'base', required: true, description: 'knowledge base name or id' }],
|
|
310
|
+
flags: {},
|
|
311
|
+
summary: 'List the agents that can read one knowledge base',
|
|
312
|
+
examples: ['frontera knowledge agents logistics-policies'],
|
|
313
|
+
},
|
|
314
|
+
async run(ctx) {
|
|
315
|
+
const ref = ctx.positional[0]
|
|
316
|
+
if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
|
|
317
|
+
|
|
318
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
319
|
+
const baseId = await resolveKnowledgeRef(api, ref)
|
|
320
|
+
const rows = (await api.knowledgeAttachments(baseId)) as Array<Record<string, unknown>>
|
|
321
|
+
|
|
322
|
+
// The attachment row carries only `agentId`, so the slug — the value every
|
|
323
|
+
// other agent command takes — has to be looked up. Worth one extra request:
|
|
324
|
+
// a table of bare uuids cannot be acted on without a second command.
|
|
325
|
+
const slugs = new Map<string, string>()
|
|
326
|
+
if (rows.length > 0) {
|
|
327
|
+
const agentRows = (await api.agents().catch(() => [])) as Array<{
|
|
328
|
+
id?: string
|
|
329
|
+
agentId?: string
|
|
330
|
+
name?: string
|
|
331
|
+
}>
|
|
332
|
+
for (const a of agentRows) {
|
|
333
|
+
if (a.id && (a.agentId || a.name)) slugs.set(a.id, a.agentId ?? a.name ?? '')
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
data: rows,
|
|
339
|
+
text:
|
|
340
|
+
rows.length === 0
|
|
341
|
+
? `No agent can read ${ref} — attach one with \`frontera knowledge attach ${ref} <agent>\`.`
|
|
342
|
+
: table(
|
|
343
|
+
['agent', 'id'],
|
|
344
|
+
rows.map((r) => {
|
|
345
|
+
const id = String(r.agentId ?? '')
|
|
346
|
+
// An agent still in `configuring` is absent from `agent list`,
|
|
347
|
+
// so print the id rather than a "?" that reads as corruption.
|
|
348
|
+
return [slugs.get(id) ?? id, id]
|
|
349
|
+
}),
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
111
355
|
/**
|
|
112
|
-
* `
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
356
|
+
* `delete` stays reserved.
|
|
357
|
+
*
|
|
358
|
+
* Deleting a base destroys its chunks and every agent attachment with it, and
|
|
359
|
+
* the route takes no confirmation of what is about to be lost — so the CLI
|
|
360
|
+
* would be the easiest way to do the most damage. It waits for the count of
|
|
361
|
+
* what will go with it.
|
|
116
362
|
*/
|
|
117
|
-
const RESERVED = 'knowledge
|
|
363
|
+
const RESERVED = 'deleting a knowledge base is not available in this release; delete it from the Console'
|
|
118
364
|
|
|
119
365
|
const reserved: Command[] = (
|
|
120
|
-
[
|
|
121
|
-
['create', 'Create a knowledge base'],
|
|
122
|
-
['upload', 'Upload files into a knowledge base'],
|
|
123
|
-
['delete', 'Delete a knowledge base'],
|
|
124
|
-
] as const
|
|
366
|
+
[['delete', 'Delete a knowledge base']] as const
|
|
125
367
|
).map(([verb, summary]) => ({
|
|
126
368
|
meta: {
|
|
127
369
|
noun: 'knowledge',
|
|
@@ -137,4 +379,13 @@ const reserved: Command[] = (
|
|
|
137
379
|
},
|
|
138
380
|
}))
|
|
139
381
|
|
|
140
|
-
export const knowledgeCommands: Command[] = [
|
|
382
|
+
export const knowledgeCommands: Command[] = [
|
|
383
|
+
list,
|
|
384
|
+
sources,
|
|
385
|
+
create,
|
|
386
|
+
upload,
|
|
387
|
+
attach,
|
|
388
|
+
detach,
|
|
389
|
+
agents,
|
|
390
|
+
...reserved,
|
|
391
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import { mimeFor, type PlannedFile, type UploadPlan } from './upload-plan'
|
|
5
|
+
|
|
6
|
+
export interface UploadOutcome {
|
|
7
|
+
file: string
|
|
8
|
+
status: 'uploaded' | 'failed'
|
|
9
|
+
sourceId?: string
|
|
10
|
+
error?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What `uploadAll` needs from `PlatformApi`, so a test can supply it. */
|
|
14
|
+
export interface SourceUploader {
|
|
15
|
+
uploadKnowledgeSource(
|
|
16
|
+
baseId: string,
|
|
17
|
+
file: { bytes: Uint8Array; filename: string; mime: string },
|
|
18
|
+
opts?: { extractionStrategy?: string },
|
|
19
|
+
): Promise<{ id?: string; status?: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Four at a time.
|
|
24
|
+
*
|
|
25
|
+
* Each upload is a whole file over the wire followed by an S3 put, so serial
|
|
26
|
+
* uploads make a 200-file seed minutes of mostly waiting. Four keeps the burst
|
|
27
|
+
* small enough that a wrong credential or a spent quota shows up in a short
|
|
28
|
+
* report rather than after 200 attempts.
|
|
29
|
+
*/
|
|
30
|
+
export const UPLOAD_CONCURRENCY = 4
|
|
31
|
+
|
|
32
|
+
export function basename(path: string): string {
|
|
33
|
+
const slash = path.lastIndexOf('/')
|
|
34
|
+
return slash === -1 ? path : path.slice(slash + 1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Upload every planned file, and report each one.
|
|
39
|
+
*
|
|
40
|
+
* A failure NEVER ends the batch — that is the whole reason to prefer this over
|
|
41
|
+
* the browser upload it replaces, where one unreadable file in two hundred
|
|
42
|
+
* meant starting over. Outcomes come back in plan order regardless of the order
|
|
43
|
+
* they finished in, so two runs of the same directory are comparable.
|
|
44
|
+
*/
|
|
45
|
+
export async function uploadAll(
|
|
46
|
+
api: SourceUploader,
|
|
47
|
+
baseId: string,
|
|
48
|
+
files: PlannedFile[],
|
|
49
|
+
strategy?: string,
|
|
50
|
+
readBytes: (path: string) => Uint8Array = (p) => new Uint8Array(readFileSync(p)),
|
|
51
|
+
): Promise<UploadOutcome[]> {
|
|
52
|
+
const outcomes: UploadOutcome[] = new Array(files.length)
|
|
53
|
+
let next = 0
|
|
54
|
+
|
|
55
|
+
const worker = async (): Promise<void> => {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const index = next++
|
|
58
|
+
const planned = files[index]
|
|
59
|
+
if (!planned) return
|
|
60
|
+
try {
|
|
61
|
+
const source = await api.uploadKnowledgeSource(
|
|
62
|
+
baseId,
|
|
63
|
+
{
|
|
64
|
+
bytes: readBytes(planned.path),
|
|
65
|
+
filename: basename(planned.path),
|
|
66
|
+
mime: mimeFor(planned.path),
|
|
67
|
+
},
|
|
68
|
+
strategy ? { extractionStrategy: strategy } : {},
|
|
69
|
+
)
|
|
70
|
+
outcomes[index] = {
|
|
71
|
+
file: planned.path,
|
|
72
|
+
status: 'uploaded',
|
|
73
|
+
...(source.id ? { sourceId: source.id } : {}),
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
outcomes[index] = {
|
|
77
|
+
file: planned.path,
|
|
78
|
+
status: 'failed',
|
|
79
|
+
error: err instanceof Error ? err.message : String(err),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await Promise.all(
|
|
86
|
+
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker()),
|
|
87
|
+
)
|
|
88
|
+
return outcomes
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UploadSummary {
|
|
92
|
+
base: string
|
|
93
|
+
baseId: string
|
|
94
|
+
uploaded: number
|
|
95
|
+
failed: number
|
|
96
|
+
skippedByType: number
|
|
97
|
+
sources: UploadOutcome[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function summarizeUploads(
|
|
101
|
+
base: string,
|
|
102
|
+
baseId: string,
|
|
103
|
+
outcomes: UploadOutcome[],
|
|
104
|
+
plan: UploadPlan,
|
|
105
|
+
): UploadSummary {
|
|
106
|
+
return {
|
|
107
|
+
base,
|
|
108
|
+
baseId,
|
|
109
|
+
uploaded: outcomes.filter((o) => o.status === 'uploaded').length,
|
|
110
|
+
failed: outcomes.filter((o) => o.status === 'failed').length,
|
|
111
|
+
skippedByType: plan.skippedByType,
|
|
112
|
+
sources: outcomes,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The text a person reads.
|
|
118
|
+
*
|
|
119
|
+
* Failures lead. A partial batch exits 0 — the per-file report IS the useful
|
|
120
|
+
* answer, and a non-zero exit tells a caller not to parse stdout at all — so
|
|
121
|
+
* the count of what did not land has to be the first thing on the page rather
|
|
122
|
+
* than a column someone has to notice.
|
|
123
|
+
*/
|
|
124
|
+
export function renderUploadSummary(summary: UploadSummary): string {
|
|
125
|
+
const lines: string[] = []
|
|
126
|
+
const total = summary.sources.length
|
|
127
|
+
|
|
128
|
+
if (summary.failed > 0) lines.push(`${summary.failed} of ${total} FAILED — see below`)
|
|
129
|
+
lines.push(
|
|
130
|
+
`uploaded ${summary.uploaded} file(s) into ${summary.base}` +
|
|
131
|
+
(summary.skippedByType > 0
|
|
132
|
+
? `, skipped ${summary.skippedByType} of an unsupported type`
|
|
133
|
+
: ''),
|
|
134
|
+
)
|
|
135
|
+
lines.push(
|
|
136
|
+
table(
|
|
137
|
+
['file', 'status', 'detail'],
|
|
138
|
+
summary.sources.map((o) => [basename(o.file), o.status, o.error ?? o.sourceId ?? '']),
|
|
139
|
+
[50, undefined, 60],
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
// Ingestion is queued, not done: chunk counts appear minutes later, and a
|
|
143
|
+
// caller that tests retrieval immediately concludes the upload failed.
|
|
144
|
+
lines.push(` ingestion runs asynchronously — frontera knowledge sources ${summary.base}`)
|
|
145
|
+
return lines.join('\n')
|
|
146
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which file types the ingestion pipeline accepts.
|
|
6
|
+
*
|
|
7
|
+
* A mirror of `packages/service/src/internal/knowledge/accepted-file-types.ts`,
|
|
8
|
+
* which stays the authority — this exists so a 200-file directory does not
|
|
9
|
+
* become 200 rejected requests, and so an explicitly named `.zip` fails before
|
|
10
|
+
* anything is uploaded. Drift makes the CLI stricter or chattier, never more
|
|
11
|
+
* permissive: the service re-checks every file it is sent.
|
|
12
|
+
*/
|
|
13
|
+
export const ACCEPTED_EXTENSIONS = [
|
|
14
|
+
'.pdf',
|
|
15
|
+
'.docx',
|
|
16
|
+
'.xlsx',
|
|
17
|
+
'.xls',
|
|
18
|
+
'.md',
|
|
19
|
+
'.markdown',
|
|
20
|
+
'.txt',
|
|
21
|
+
'.csv',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
25
|
+
'.pdf': 'application/pdf',
|
|
26
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
27
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
28
|
+
'.xls': 'application/vnd.ms-excel',
|
|
29
|
+
'.md': 'text/markdown',
|
|
30
|
+
'.markdown': 'text/markdown',
|
|
31
|
+
'.txt': 'text/plain',
|
|
32
|
+
'.csv': 'text/csv',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extensionOf(path: string): string {
|
|
36
|
+
const dot = path.lastIndexOf('.')
|
|
37
|
+
return dot === -1 ? '' : path.slice(dot).toLowerCase()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAccepted(path: string): boolean {
|
|
41
|
+
return (ACCEPTED_EXTENSIONS as readonly string[]).includes(extensionOf(path))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Content type for a path. Unknown extensions never reach here. */
|
|
45
|
+
export function mimeFor(path: string): string {
|
|
46
|
+
return MIME_BY_EXTENSION[extensionOf(path)] ?? 'application/octet-stream'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PlannedFile {
|
|
50
|
+
path: string
|
|
51
|
+
/** Why it is in the plan — decides how a rejection is reported. */
|
|
52
|
+
source: 'named' | 'scanned'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UploadPlan {
|
|
56
|
+
files: PlannedFile[]
|
|
57
|
+
/** Named paths that cannot be uploaded, with the reason. */
|
|
58
|
+
rejected: Array<{ path: string; reason: string }>
|
|
59
|
+
/** Count of scanned files skipped for their type. Not an error. */
|
|
60
|
+
skippedByType: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Turn the paths a caller named into the files to upload.
|
|
65
|
+
*
|
|
66
|
+
* A named path and a scanned one are treated differently on purpose. Naming
|
|
67
|
+
* `policy.zip` is a mistake worth failing on; finding `.DS_Store` while walking
|
|
68
|
+
* a directory the caller pointed at is not, and failing the run for it would
|
|
69
|
+
* make directory upload unusable on any real machine. Both outcomes are
|
|
70
|
+
* reported — the skipped count is printed, never silent.
|
|
71
|
+
*/
|
|
72
|
+
export function planUploads(paths: string[]): UploadPlan {
|
|
73
|
+
const files: PlannedFile[] = []
|
|
74
|
+
const rejected: Array<{ path: string; reason: string }> = []
|
|
75
|
+
let skippedByType = 0
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
|
|
78
|
+
const push = (path: string, source: PlannedFile['source']): void => {
|
|
79
|
+
if (seen.has(path)) return
|
|
80
|
+
seen.add(path)
|
|
81
|
+
files.push({ path, source })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
let info: ReturnType<typeof statSync>
|
|
86
|
+
try {
|
|
87
|
+
info = statSync(path)
|
|
88
|
+
} catch {
|
|
89
|
+
rejected.push({ path, reason: 'no such file or directory' })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (info.isDirectory()) {
|
|
94
|
+
for (const found of walk(path)) {
|
|
95
|
+
if (isAccepted(found)) push(found, 'scanned')
|
|
96
|
+
else skippedByType += 1
|
|
97
|
+
}
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isAccepted(path)) {
|
|
102
|
+
rejected.push({
|
|
103
|
+
path,
|
|
104
|
+
reason: `unsupported type — accepted: ${ACCEPTED_EXTENSIONS.join(', ')}`,
|
|
105
|
+
})
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
push(path, 'named')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sorted so a re-run reports in the same order, which is what makes two runs
|
|
112
|
+
// comparable at all.
|
|
113
|
+
files.sort((a, b) => a.path.localeCompare(b.path))
|
|
114
|
+
return { files, rejected, skippedByType }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Depth-first walk, dotfiles and dot-directories excluded. */
|
|
118
|
+
function walk(dir: string): string[] {
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
121
|
+
if (entry.name.startsWith('.')) continue
|
|
122
|
+
const path = join(dir, entry.name)
|
|
123
|
+
if (entry.isDirectory()) out.push(...walk(path))
|
|
124
|
+
else if (entry.isFile()) out.push(path)
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|