mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
fix(cli): exit codes, error hints and run id for server operations
- Logged out, a word no local command claims is not_logged_in (exit 4); logged in, a path no command or op matches is unknown_op (exit 6). A near miss of a local command stays exit 2. - A 403 keeps the server's hint instead of telling the user to log in. - The unknown_op hint points at difyctl help, not the removed ops command. - A folded run keeps run_id from the stream's workflow_run_id. - A downloaded file keeps only the base name the server sent.
This commit is contained in:
+1
-1
@@ -74,7 +74,7 @@ import { ErrorCode } from '@/errors/codes'
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'workspace id required',
|
||||
hint: "pass --workspace or run 'difyctl use workspace <id>'",
|
||||
hint: "pass --workspace-id or run 'difyctl use workspace <id>'",
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -148,10 +148,28 @@ it('a paused run is exit 0 with status suspended and the server hints', async ()
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'suspended',
|
||||
run_id: 'wf-run-hitl-1',
|
||||
hints: [{ op: 'submit.run.form', input: { form_token: 'ft-hitl-1', action: 'submit' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a form submit prints the hint to follow the run', async () => {
|
||||
const w = await world([
|
||||
'submit',
|
||||
'run',
|
||||
'form',
|
||||
'--input',
|
||||
'{"app_id":"app-2","form_token":"ft-hitl-1","action":"submit","inputs":{"name":"Bob"}}',
|
||||
])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf()).hints).toEqual([
|
||||
expect.objectContaining({
|
||||
op: 'get.run.event',
|
||||
input: { app_id: 'app-2', task_id: 'wf-run-hitl-1', continue_on_pause: false },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('a stale catalog is refreshed once and the op is retried', async () => {
|
||||
const w = await world(['help'])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
|
||||
@@ -91,3 +91,18 @@ it('copies server hints from a pause and suspends; a pause without hints is stil
|
||||
])
|
||||
expect(bare.result).toEqual({ status: 'suspended', text: { answer: '' }, hints: [] })
|
||||
})
|
||||
|
||||
it('keeps the run id from any event, even one it has no handler for', () => {
|
||||
const { result, code } = run([
|
||||
['workflow_started', { workflow_run_id: 'run-1', data: { id: 'run-1' } }],
|
||||
['human_input_required', { workflow_run_id: 'run-1', hints: [] }],
|
||||
['workflow_paused', { workflow_run_id: 'run-1' }],
|
||||
])
|
||||
expect(result).toMatchObject({ status: 'suspended', run_id: 'run-1' })
|
||||
expect(code).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores a run id that is not a string', () => {
|
||||
const { result } = run([['some_future_event', { workflow_run_id: 42 }]])
|
||||
expect(result).not.toHaveProperty('run_id')
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ExitCodeValue } from '@/errors/codes'
|
||||
import type { FoldResult } from '@/protocol/fold'
|
||||
import { ExitCode } from '@/errors/codes'
|
||||
import { FOLD_STATUS, FOLD_TABLE } from '@/protocol/fold'
|
||||
import { FOLD_STATUS, FOLD_TABLE, foldAnyEvent } from '@/protocol/fold'
|
||||
|
||||
export function newFoldResult(): FoldResult {
|
||||
return { status: FOLD_STATUS.Incomplete, text: { answer: '' }, hints: [] }
|
||||
}
|
||||
|
||||
export function foldEvent(result: FoldResult, name: string, event: Record<string, unknown>): void {
|
||||
const handler = FOLD_TABLE[name]
|
||||
if (handler !== undefined) handler(result, event)
|
||||
foldAnyEvent(result, event)
|
||||
FOLD_TABLE[name]?.(result, event)
|
||||
}
|
||||
|
||||
export function finishFold(result: FoldResult): FoldResult {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Renderer } from './index'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { join, posix } from 'node:path'
|
||||
import { unknownError } from '@/errors/base'
|
||||
import { renderJson } from './object'
|
||||
|
||||
@@ -21,10 +21,18 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
|
||||
const QUOTED_FILENAME = /filename="([^"]+)"/
|
||||
const BARE_FILENAME = /filename=([^;]+)/
|
||||
const NOT_A_FILE_NAME: readonly string[] = ['', '.', '..']
|
||||
|
||||
// Only the last part of the server's name is used, so the file stays in the folder.
|
||||
function baseName(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined) return undefined
|
||||
const name = posix.basename(raw.replaceAll('\\', '/'))
|
||||
return NOT_A_FILE_NAME.includes(name) ? undefined : name
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(value: string | null): string | undefined {
|
||||
if (value === null) return undefined
|
||||
return QUOTED_FILENAME.exec(value)?.[1] ?? BARE_FILENAME.exec(value)?.[1]?.trim()
|
||||
return baseName(QUOTED_FILENAME.exec(value)?.[1] ?? BARE_FILENAME.exec(value)?.[1]?.trim())
|
||||
}
|
||||
|
||||
function extensionOf(contentType: string | null): string {
|
||||
|
||||
@@ -138,3 +138,32 @@ it('file names an unnamed download after the op and its content type', async ()
|
||||
process.chdir(cwd)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['../../escape.txt', 'escape.txt'],
|
||||
['/etc/passwd', 'passwd'],
|
||||
['..\\..\\win.txt', 'win.txt'],
|
||||
['..', `${OP_ID}.txt`],
|
||||
['dir/', 'dir'],
|
||||
])('file keeps only the base name of the server filename %s', async (sent, kept) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-file-'))
|
||||
const cwd = process.cwd()
|
||||
process.chdir(dir)
|
||||
const here = process.cwd()
|
||||
try {
|
||||
const r = render(
|
||||
'file',
|
||||
new Response('x', {
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
'content-disposition': `attachment; filename="${sent}"`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await r.code
|
||||
expect(JSON.parse(r.buf.outBuf())).toMatchObject({ path: join(here, kept) })
|
||||
expect(readFileSync(join(here, kept), 'utf8')).toBe('x')
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(async () => {
|
||||
|
||||
it('prints the envelope on stderr and returns the exit code', async () => {
|
||||
w = await testContext({ login: false })
|
||||
const code = await main(['nope'], w.overrides)
|
||||
const code = await main(['versoin'], w.overrides)
|
||||
expect(code).toBe(2)
|
||||
expect(JSON.parse(w.io.errBuf()).error.code).toBe('usage_invalid_flag')
|
||||
expect(w.io.outBuf()).toBe('')
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TestWorld } from '@test/fixtures/kernel'
|
||||
import type { BaseError } from '@/errors/base'
|
||||
import type { CommandTree } from '@/plugins/commands/registry'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { testContext } from '@test/fixtures/kernel'
|
||||
import { afterEach, expect, it } from 'vite-plus/test'
|
||||
import { z } from 'zod'
|
||||
import { ExitCode } from '@/errors/codes'
|
||||
import { parseCatalog } from '@/plugins/catalog'
|
||||
import { Command } from '@/plugins/commands/command'
|
||||
import { propertiesOf } from '@/protocol/shape'
|
||||
@@ -218,7 +220,7 @@ it('takes --verbose anywhere and keeps it out of the command input', async () =>
|
||||
const b = await world(false, ['--verbose', 'version'])
|
||||
expect(await (await b.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(b.io.outBuf()).client.version).toBe('0.0.0-test')
|
||||
const c = await world(false, ['nope', '--verbose'])
|
||||
const c = await world(false, ['versoin', '--verbose'])
|
||||
await expect((await c.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
})
|
||||
@@ -254,21 +256,32 @@ it('a command word may be spelled with dashes where the op id has underscores',
|
||||
})
|
||||
})
|
||||
|
||||
it('an unknown path refetches the catalog once before giving up', async () => {
|
||||
it('an unknown path refetches the catalog once, then is unknown_op (exit 6)', async () => {
|
||||
const w = await world(true, ['get', 'qqqq'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: 'unknown command: get qqqq',
|
||||
const miss = (await w.ctx.get(commands)).run()
|
||||
await expect(miss).rejects.toMatchObject({
|
||||
code: 'unknown_op',
|
||||
message: 'no command or server operation named get qqqq',
|
||||
hint: 'run difyctl help',
|
||||
})
|
||||
await expect(miss).rejects.toSatisfy((err: BaseError) => err.exit() === ExitCode.Catalog)
|
||||
expect(w.mock.requestCount).toBe(2) // the cached catalog, then one refetch
|
||||
|
||||
const typo = await world(true, ['get', 'consol_app'])
|
||||
await expect((await typo.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'unknown_op',
|
||||
hint: 'did you mean: get console_app, get console_app version',
|
||||
})
|
||||
})
|
||||
|
||||
it('a typo of a static command stays exit 2 once the catalog is loaded', async () => {
|
||||
const w = await world(true, ['versoin'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
hint: 'did you mean: version',
|
||||
})
|
||||
})
|
||||
|
||||
it('a known namespace and a help view answer from the cached catalog, with no refetch', async () => {
|
||||
const a = await world(true, ['run'])
|
||||
expect(await (await a.ctx.get(commands)).run()).toBe(0)
|
||||
@@ -302,13 +315,21 @@ it('an unreachable catalog costs a notice, not the static suggestion', async ()
|
||||
expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /)
|
||||
})
|
||||
|
||||
it('without a login only statics resolve and the hint says to log in', async () => {
|
||||
it('without a login a word no static claims is not_logged_in (exit 4)', async () => {
|
||||
const w = await world(false, ['get', 'console_app'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: 'unknown command: get console_app',
|
||||
hint: 'log in to use server operations',
|
||||
const miss = (await w.ctx.get(commands)).run()
|
||||
await expect(miss).rejects.toMatchObject({
|
||||
code: 'not_logged_in',
|
||||
message: 'not logged in: get console_app may be a server operation; log in to load them',
|
||||
hint: 'run difyctl help login',
|
||||
})
|
||||
await expect(miss).rejects.toSatisfy((err: BaseError) => err.exit() === ExitCode.Auth)
|
||||
expect(w.mock.requestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('without a login a namespace still lists its static commands', async () => {
|
||||
const w = await world(false, ['get'])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(w.mock.requestCount).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const FULL_FLAG = '--full'
|
||||
const ALL_FLAG = '--all'
|
||||
const FLAG_PREFIX = '-'
|
||||
const NO_SERVER_NOTICE = notLoggedInMessage(helpHint('login'))
|
||||
const NO_SERVER_HINT = 'log in to use server operations'
|
||||
const MAYBE_AN_OP = 'may be a server operation; log in to load them'
|
||||
const OPS_UNAVAILABLE_PREFIX = 'could not list server operations: '
|
||||
const NO_MATCH_NOTICE = `nothing matched; ${helpHint()} for the map`
|
||||
const NO_REST: readonly string[] = []
|
||||
@@ -96,22 +96,54 @@ async function fromOps<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function unknownCommand(tree: CommandTree, words: readonly string[], fallback: string): BaseError {
|
||||
function suggestionHint(tree: CommandTree, words: readonly string[]): string | undefined {
|
||||
const suggestions = findSuggestions(tree, [...words])
|
||||
return suggestions.length > 0 ? `did you mean: ${suggestions.join(', ')}` : undefined
|
||||
}
|
||||
|
||||
function unknownCommand(words: readonly string[], hint: string): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown command: ${words.join(COMMAND_SEPARATOR)}`,
|
||||
hint: suggestions.length > 0 ? `did you mean: ${suggestions.join(', ')}` : fallback,
|
||||
hint,
|
||||
})
|
||||
}
|
||||
|
||||
/** The error for words no command claimed, given the widest tree resolution got. */
|
||||
type Miss = (tree: CommandTree, words: readonly string[]) => BaseError
|
||||
|
||||
const unresolved: Miss = (tree, words) =>
|
||||
unknownCommand(words, suggestionHint(tree, words) ?? helpHint())
|
||||
|
||||
// A near miss of a static command is a typo (exit 2) whether or not ops could load.
|
||||
function unlessStaticTypo(statics: CommandTree, miss: Miss): Miss {
|
||||
return (tree, words) => {
|
||||
const typo = suggestionHint(statics, words)
|
||||
return typo === undefined ? miss(tree, words) : unknownCommand(words, typo)
|
||||
}
|
||||
}
|
||||
|
||||
const loggedOut: Miss = (_tree, words) =>
|
||||
new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: notLoggedInMessage(`${words.join(COMMAND_SEPARATOR)} ${MAYBE_AN_OP}`),
|
||||
hint: helpHint('login'),
|
||||
})
|
||||
|
||||
const unknownOp: Miss = (tree, words) =>
|
||||
new BaseError({
|
||||
code: ErrorCode.UnknownOp,
|
||||
message: `no command or server operation named ${words.join(COMMAND_SEPARATOR)}`,
|
||||
hint: suggestionHint(tree, words) ?? helpHint(),
|
||||
})
|
||||
|
||||
type Discovery = Readonly<{
|
||||
tree: CommandTree
|
||||
path: readonly string[]
|
||||
full: boolean
|
||||
listing: CollectOptions
|
||||
wantsHelp: boolean
|
||||
missHint?: string
|
||||
miss: Miss
|
||||
ctx: CommandContext
|
||||
streams: IOService
|
||||
}>
|
||||
@@ -140,7 +172,7 @@ async function discover(args: Discovery): Promise<number> {
|
||||
await streams.document(listingView(under))
|
||||
return ExitCode.Success
|
||||
}
|
||||
if (!args.wantsHelp) throw unknownCommand(tree, path, args.missHint ?? helpHint())
|
||||
if (!args.wantsHelp) throw args.miss(tree, path)
|
||||
|
||||
const found = helpSearch(path, tree, listing)
|
||||
if (found.total === 0) streams.notice(NO_MATCH_NOTICE)
|
||||
@@ -151,8 +183,8 @@ async function discover(args: Discovery): Promise<number> {
|
||||
type Resolution = {
|
||||
readonly resolved: ResolvedCommand | undefined
|
||||
readonly tree: CommandTree
|
||||
/** Set only when a miss has a better explanation than the generic help hint. */
|
||||
readonly missHint?: string
|
||||
/** Set only when a miss has a better explanation than an unknown command. */
|
||||
readonly miss?: Miss
|
||||
}
|
||||
|
||||
type Walk = Readonly<{
|
||||
@@ -181,9 +213,9 @@ async function resolveWithOps(walk: Walk): Promise<Resolution> {
|
||||
const direct = resolveCommand(tree, path)
|
||||
if (direct !== undefined) return { resolved: direct, tree }
|
||||
|
||||
// A help view explains a missing catalog on stderr; a plain miss gets the hint instead.
|
||||
// A help view explains a missing catalog on stderr; a plain miss says to log in.
|
||||
if (!walk.wantsHelp && (await (await ctx.get(session)).current()) === null)
|
||||
return { resolved: undefined, tree, missHint: NO_SERVER_HINT }
|
||||
return { resolved: undefined, tree, miss: unlessStaticTypo(tree, loggedOut) }
|
||||
|
||||
const known = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP))
|
||||
if (known === undefined) return { resolved: undefined, tree }
|
||||
@@ -195,7 +227,11 @@ async function resolveWithOps(walk: Walk): Promise<Resolution> {
|
||||
const refetched = await fromOps(ctx, streams, (service) => service.commands(EVERY_OP_FRESH))
|
||||
if (refetched === undefined) return { resolved: undefined, tree: cached }
|
||||
const fresh = mergeTrees(tree, refetched).tree
|
||||
return { resolved: resolveCommand(fresh, path), tree: fresh }
|
||||
return {
|
||||
resolved: resolveCommand(fresh, path),
|
||||
tree: fresh,
|
||||
miss: unlessStaticTypo(tree, unknownOp),
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPipeline(tree: CommandTree, ctx: CommandContext): Promise<number> {
|
||||
@@ -225,7 +261,7 @@ export async function runPipeline(tree: CommandTree, ctx: CommandContext): Promi
|
||||
full: argv.includes(FULL_FLAG),
|
||||
listing: { includeHidden: argv.includes(ALL_FLAG) },
|
||||
wantsHelp,
|
||||
missHint: walked.missHint,
|
||||
miss: walked.miss ?? unresolved,
|
||||
ctx,
|
||||
streams,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { FORBIDDEN_HINT, startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { Context } from '@/kernel/context'
|
||||
import { catalog, CATALOG_PATH } from '@/plugins/catalog'
|
||||
@@ -15,22 +15,20 @@ let mock: DifyMock
|
||||
let dir: string
|
||||
|
||||
function writeTimeout(ms: number): void {
|
||||
writeFileSync(join(dir, CONFIG_FILE_NAME), `schema_version: 1\nhttp:\n timeout: ${ms}\n`)
|
||||
writeFileSync(join(dir, CONFIG_FILE_NAME), `http:\n timeout: ${ms}\n`)
|
||||
}
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
dir = mkdtempSync(join(tmpdir(), 'difyctl-http-'))
|
||||
Object.assign(process.env, {
|
||||
DIFY_CONFIG_DIR: dir,
|
||||
DIFY_CACHE_DIR: dir,
|
||||
DIFY_SERVER: mock.url,
|
||||
DIFY_TOKEN: 'dfoa_test',
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
for (const k of ['DIFY_CONFIG_DIR', 'DIFY_CACHE_DIR', 'DIFY_SERVER', 'DIFY_TOKEN'])
|
||||
delete process.env[k]
|
||||
for (const k of ['DIFY_CONFIG_DIR', 'DIFY_SERVER', 'DIFY_TOKEN']) delete process.env[k]
|
||||
})
|
||||
|
||||
const account = () => ({ method: 'GET', path: '/openapi/v1/account' })
|
||||
@@ -103,6 +101,35 @@ describe('http plugin', () => {
|
||||
await expect(h.request(account)).rejects.toMatchObject({ code: 'rate_limited' })
|
||||
})
|
||||
|
||||
it('a 401 says to log in again', async () => {
|
||||
const h = await new Context().get(http)
|
||||
mock.setScenario('auth-expired')
|
||||
await expect(h.request(account)).rejects.toMatchObject({ hint: 'run difyctl login' })
|
||||
})
|
||||
|
||||
it("a 403 keeps the server's hint and never says to log in", async () => {
|
||||
const h = await new Context().get(http)
|
||||
mock.setScenario('forbidden')
|
||||
await expect(h.request(account)).rejects.toMatchObject({
|
||||
code: 'access_denied',
|
||||
message: 'you may not do this',
|
||||
hint: FORBIDDEN_HINT,
|
||||
})
|
||||
})
|
||||
|
||||
it('a 403 without a server hint says permission denied', async () => {
|
||||
process.env.DIFY_TOKEN = 'sk-test-resource'
|
||||
const h = await new Context().get(http)
|
||||
mock.setScenario('resource-token')
|
||||
await expect(
|
||||
h.request(() => ({ method: 'GET', path: '/openapi/v1/account' })),
|
||||
).rejects.toMatchObject({
|
||||
code: 'access_denied',
|
||||
httpStatus: 403,
|
||||
hint: expect.stringMatching(/^permission denied/),
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces 5xx as server_error with the server body and details', async () => {
|
||||
const h = await new Context().get(http)
|
||||
mock.setScenario('server-5xx')
|
||||
@@ -166,7 +193,7 @@ describe('http plugin', () => {
|
||||
await expect((await new Context().get(http)).request(account)).rejects.toMatchObject({
|
||||
code: 'catalog_unavailable',
|
||||
message: `failed to fetch the catalog: HTTP 404 from ${CATALOG_PATH}`,
|
||||
hint: expect.stringContaining('refresh cache'),
|
||||
hint: expect.stringContaining('server is reachable'),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ const HttpStatus = {
|
||||
|
||||
const CATALOG_STALE_CODE = 'catalog_stale'
|
||||
const CATALOG_UNAVAILABLE_PREFIX = 'failed to fetch the catalog: '
|
||||
const CATALOG_UNAVAILABLE_HINT = 'check the server URL or run difyctl refresh cache'
|
||||
// `refresh cache` would only repeat the fetch that failed, so it is not suggested.
|
||||
const CATALOG_UNAVAILABLE_HINT =
|
||||
'check that the saved server URL is right and the server is reachable, then retry'
|
||||
|
||||
// The catalog fetch is the CLI's first request, so its failure has to name itself:
|
||||
// a rejected status for the catalog route, or whatever the network said.
|
||||
@@ -51,11 +53,15 @@ function catalogFailure(cause: unknown): string {
|
||||
return errorMessage(cause)
|
||||
}
|
||||
|
||||
type StatusMapping = Readonly<{ code: ErrorCodeValue; hint?: string }>
|
||||
const PERMISSION_DENIED_HINT = 'permission denied: this account may not do this'
|
||||
|
||||
// `hint` replaces the server's hint; `fallbackHint` only fills in when it sent none.
|
||||
type StatusMapping = Readonly<{ code: ErrorCodeValue; hint?: string; fallbackHint?: string }>
|
||||
|
||||
const STATUS_MAPPINGS: Readonly<Record<number, StatusMapping>> = {
|
||||
[HttpStatus.Unauthorized]: { code: ErrorCode.AuthExpired, hint: LOGIN_HINT },
|
||||
[HttpStatus.Forbidden]: { code: ErrorCode.AccessDenied, hint: LOGIN_HINT },
|
||||
// Logging in again does not grant a permission, and `login` revokes the session first.
|
||||
[HttpStatus.Forbidden]: { code: ErrorCode.AccessDenied, fallbackHint: PERMISSION_DENIED_HINT },
|
||||
[HttpStatus.TooManyRequests]: { code: ErrorCode.RateLimited },
|
||||
}
|
||||
|
||||
@@ -196,11 +202,11 @@ export const http = definePlugin({
|
||||
|
||||
const rawResponse = await res.text()
|
||||
const serverError = parseServerErrorBody(rawResponse)
|
||||
const { code, hint } = classifyStatus(res)
|
||||
const { code, hint, fallbackHint } = classifyStatus(res)
|
||||
throw new HttpClientError({
|
||||
code,
|
||||
message: serverError?.message ?? `HTTP ${res.status}`,
|
||||
hint: hint ?? serverError?.hint,
|
||||
hint: hint ?? serverError?.hint ?? fallbackHint,
|
||||
httpStatus: res.status,
|
||||
method: req.method,
|
||||
url: url.toString(),
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FoldResult = {
|
||||
total_tokens?: number
|
||||
message_id?: string
|
||||
conversation_id?: string
|
||||
run_id?: string
|
||||
error?: { message: string; code?: string }
|
||||
hints: Hint[]
|
||||
}
|
||||
@@ -108,6 +109,12 @@ function markError(result: FoldResult, event: Record<string, unknown>): void {
|
||||
raise(result, FOLD_STATUS.Failed)
|
||||
}
|
||||
|
||||
/** Runs for every event before its own handler: any workflow event names its run. */
|
||||
export const foldAnyEvent: EventHandler = (result, event) => {
|
||||
const runId = asString(event.workflow_run_id)
|
||||
if (runId !== undefined) result.run_id = runId
|
||||
}
|
||||
|
||||
export const FOLD_TABLE: Readonly<Record<string, EventHandler>> = Object.freeze({
|
||||
message: appendAnswer,
|
||||
agent_message: appendAnswer,
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export type Scenario =
|
||||
| 'denied'
|
||||
| 'expired'
|
||||
| 'auth-expired'
|
||||
| 'forbidden'
|
||||
| 'rate-limited'
|
||||
| 'server-5xx'
|
||||
| 'slow-down'
|
||||
|
||||
+23
-4
@@ -53,6 +53,7 @@ const EMPTY_ERROR_PATH = '/empty-error'
|
||||
const BAD_VERIFICATION_URI = 'ftp://elsewhere.example/device'
|
||||
const HANG_MS = 30_000
|
||||
const OAUTH_PREFIX = '/openapi/v1/oauth/'
|
||||
export const FORBIDDEN_HINT = 'ask a workspace owner for the editor role'
|
||||
|
||||
const ServerErrorCode = {
|
||||
Unauthorized: 'unauthorized',
|
||||
@@ -71,6 +72,7 @@ const OpId = {
|
||||
ChatRun: 'run.console_app.chat',
|
||||
AdvancedChatRun: 'run.console_app.advanced_chat',
|
||||
FormSubmit: 'submit.run.form',
|
||||
RunEvents: 'get.run.event',
|
||||
} as const
|
||||
|
||||
const CATALOG_STALE_MESSAGE =
|
||||
@@ -195,7 +197,13 @@ function streamingRunResponse(
|
||||
|
||||
function hitlPauseResponse(appId: string): string {
|
||||
return sseChunks([
|
||||
{ event: 'workflow_started', data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' } },
|
||||
{
|
||||
event: 'workflow_started',
|
||||
data: {
|
||||
workflow_run_id: 'wf-run-hitl-1',
|
||||
data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' },
|
||||
},
|
||||
},
|
||||
{ event: 'node_started', data: { id: 'n1', title: 'First Node' } },
|
||||
{
|
||||
event: 'human_input_required',
|
||||
@@ -435,6 +443,8 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ code: 'forbidden', message: 'resource_not_authorized', status: 403 }, 403)
|
||||
const scenario = getScenario()
|
||||
if (scenario === 'auth-expired') return unauthorized()
|
||||
if (scenario === 'forbidden')
|
||||
return errorResponse(ServerErrorCode.Forbidden, 'you may not do this', 403, FORBIDDEN_HINT)
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -747,9 +757,18 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
return c.json({ result: 'success' })
|
||||
})
|
||||
|
||||
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) => {
|
||||
return c.json({})
|
||||
})
|
||||
// Like the server: the submit names the run to follow, stopping at the next pause.
|
||||
app.post('/openapi/v1/apps/:id/human-input-forms/:formToken:submit', (c) =>
|
||||
c.json({
|
||||
hints: [
|
||||
{
|
||||
summary: 'Follow the run',
|
||||
op: OpId.RunEvents,
|
||||
input: { app_id: c.req.param('id'), task_id: 'wf-run-hitl-1', continue_on_pause: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
app.get('/openapi/v1/apps/:id/tasks/:task_id/events', (_c) => sseResponse(hitlResumedResponse()))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user