@frontera-sdk/cli 1.50.79 → 1.50.81
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/README.md +6 -0
- package/package.json +4 -4
- package/src/api/blueprint-authoring-api.ts +52 -0
- package/src/api/credential-failure.ts +34 -4
- package/src/api/dataset-api.ts +1 -1
- package/src/api/governed-action-api.ts +75 -7
- package/src/api/media-api.ts +181 -0
- package/src/api/platform-api.ts +59 -0
- package/src/api/workflow-api.ts +1 -1
- package/src/auth-verify.ts +1 -1
- package/src/commands/action/available.ts +59 -0
- package/src/commands/action/cancel.ts +34 -0
- package/src/commands/action/decide.ts +54 -0
- package/src/commands/action/deploy.ts +4 -1
- package/src/commands/action/index-commands.ts +16 -6
- package/src/commands/action/prepare.ts +3 -1
- package/src/commands/action/request-summary.ts +13 -0
- package/src/commands/action/requests.ts +6 -5
- package/src/commands/action/submit.ts +183 -0
- package/src/commands/blueprint/get.ts +139 -29
- package/src/commands/chat-app/index-commands.ts +475 -0
- package/src/commands/knowledge/upload-plan.ts +14 -8
- package/src/commands/marking/index-commands.ts +171 -0
- package/src/commands/media/index-commands.ts +592 -0
- package/src/commands/registry.ts +28 -6
- package/src/commands/types.ts +6 -0
- package/src/commands/workflow/list.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/exit.ts +126 -1
- package/src/flag-help.ts +24 -2
- package/src/forge/touches.ts +2 -0
- package/src/harness.ts +5 -3
- package/src/main.ts +23 -38
- package/src/scopes.ts +43 -0
- package/src/template.ts +6 -4
- package/src/templates/next-app-files.ts +8 -4
- package/src/vendor/sdk-sources.json +16 -15
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { flagString, type Command, type CommandContext } from '../types'
|
|
4
|
+
import { requestSummary } from './request-summary'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Approve or reject a request waiting on approval.
|
|
8
|
+
*
|
|
9
|
+
* Session-lane, and a person's decision in a stronger sense than submit: the
|
|
10
|
+
* approver needs `actionApproval: ['approve' | 'reject']` in their stored
|
|
11
|
+
* organization role, membership in the request's workspace, and — when the
|
|
12
|
+
* Action declares separation of duties — not to be the person who raised it.
|
|
13
|
+
* That last refusal reads "Approval separation of duties was not satisfied."
|
|
14
|
+
* With a threshold above one, an approval can leave the request still
|
|
15
|
+
* `awaiting_approval` until enough other people decide.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function decision(verb: 'approve' | 'reject'): Command {
|
|
19
|
+
return {
|
|
20
|
+
meta: {
|
|
21
|
+
noun: 'action',
|
|
22
|
+
verb,
|
|
23
|
+
authLane: 'session',
|
|
24
|
+
// The Requests tab of the Actions page carries the same two decisions.
|
|
25
|
+
sessionAlternative: 'Blueprint → Actions → Requests',
|
|
26
|
+
scope: 'workspace' as const,
|
|
27
|
+
args: [{ name: 'requestId', required: true, description: `The request to ${verb}` }],
|
|
28
|
+
flags: { reason: 'string' },
|
|
29
|
+
summary: verb === 'approve'
|
|
30
|
+
? 'Approve an Action request waiting on approval'
|
|
31
|
+
: 'Reject an Action request waiting on approval',
|
|
32
|
+
examples: [`frontera action ${verb} <requestId> --reason "Checked against the ticket"`],
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async run(ctx: CommandContext) {
|
|
36
|
+
const requestId = ctx.positional[0]
|
|
37
|
+
if (!requestId) {
|
|
38
|
+
throw new UsageError('missing <requestId>', 'frontera action requests --lifecycle awaiting_approval')
|
|
39
|
+
}
|
|
40
|
+
// Required by the service, and recorded with the decision.
|
|
41
|
+
const reason = flagString(ctx, 'reason')
|
|
42
|
+
if (!reason) {
|
|
43
|
+
throw new UsageError('missing --reason', `frontera action ${verb} ${requestId} --reason "…"`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
47
|
+
.decideApproval(requestId, verb, reason)
|
|
48
|
+
return { data: result, text: requestSummary(result.request) }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const actionApprove = decision('approve')
|
|
54
|
+
export const actionReject = decision('reject')
|
|
@@ -23,7 +23,10 @@ export const actionDeploy: Command = {
|
|
|
23
23
|
meta: {
|
|
24
24
|
noun: 'action',
|
|
25
25
|
verb: 'deploy',
|
|
26
|
-
|
|
26
|
+
// The three writes and the catalog reads are on programmatic routers
|
|
27
|
+
// (`governed-action-deployment-router.ts`, `blueprint-router.ts`), so an
|
|
28
|
+
// organization key carrying `actionMutationPlan`/`actionBinding` runs this.
|
|
29
|
+
authLane: 'api-key',
|
|
27
30
|
args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
|
|
28
31
|
flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
|
|
29
32
|
summary: 'Build and validate the write path for a published Action',
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { actionAvailable } from './available'
|
|
2
|
+
import { actionCancel } from './cancel'
|
|
3
|
+
import { actionApprove, actionReject } from './decide'
|
|
1
4
|
import { actionDeploy } from './deploy'
|
|
2
5
|
import { actionGrant } from './grant'
|
|
3
6
|
import { actionList } from './list'
|
|
4
7
|
import { actionPrepare } from './prepare'
|
|
5
8
|
import { actionRequests } from './requests'
|
|
6
9
|
import { actionReview } from './review'
|
|
10
|
+
import { actionSubmit } from './submit'
|
|
7
11
|
import type { Command } from '../types'
|
|
8
12
|
|
|
9
13
|
/**
|
|
@@ -16,13 +20,14 @@ import type { Command } from '../types'
|
|
|
16
20
|
* review and a state-machine activation. Five admin calls with hand-minted
|
|
17
21
|
* UUIDs, or nothing.
|
|
18
22
|
*
|
|
19
|
-
* Invoking
|
|
20
|
-
* non-member principal — `wskey:…` and
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* Invoking — `available`, `submit`, `approve`, `reject`, `cancel` — is
|
|
24
|
+
* session-lane. Both key kinds present a non-member principal — `wskey:…` and
|
|
25
|
+
* `orgkey:…` — and the invoke check joins organization membership, so those
|
|
26
|
+
* verbs need the credential of a person: a session token in `FRONTERA_TOKEN`,
|
|
27
|
+
* which `frontera login` cannot store.
|
|
23
28
|
*
|
|
24
|
-
* READING those invocations is not the same boundary, and `requests`
|
|
25
|
-
* the list gates on `actionRequest: ['read']` rather than on membership. The
|
|
29
|
+
* READING those invocations is not the same boundary, and `requests` runs on a
|
|
30
|
+
* key: the list gates on `actionRequest: ['read']` rather than on membership. The
|
|
26
31
|
* distinction matters because an armed path that fails every request looks
|
|
27
32
|
* exactly like one nothing has tried, and until now nothing in the CLI could
|
|
28
33
|
* tell the two apart.
|
|
@@ -30,6 +35,11 @@ import type { Command } from '../types'
|
|
|
30
35
|
export const actionCommands: readonly Command[] = [
|
|
31
36
|
actionList,
|
|
32
37
|
actionRequests,
|
|
38
|
+
actionAvailable,
|
|
39
|
+
actionSubmit,
|
|
40
|
+
actionApprove,
|
|
41
|
+
actionReject,
|
|
42
|
+
actionCancel,
|
|
33
43
|
actionPrepare,
|
|
34
44
|
actionDeploy,
|
|
35
45
|
actionReview,
|
|
@@ -17,7 +17,9 @@ export const actionPrepare: Command = {
|
|
|
17
17
|
meta: {
|
|
18
18
|
noun: 'action',
|
|
19
19
|
verb: 'prepare',
|
|
20
|
-
|
|
20
|
+
// `POST /deployments` moved to the programmatic deployment router, so an
|
|
21
|
+
// organization key carrying `actionBinding` runs this.
|
|
22
|
+
authLane: 'api-key',
|
|
21
23
|
args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
|
|
22
24
|
flags: { reason: 'string' },
|
|
23
25
|
summary: 'Record that a drafted Action ships disabled, so it can be published',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ActionRequestSummary } from '../../api/governed-action-api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line for a request a write verb just changed.
|
|
5
|
+
*
|
|
6
|
+
* `lifecycle` is what the caller acts on next — `awaiting_approval` means
|
|
7
|
+
* someone else has to `action approve`, `ready` means dispatch is queued.
|
|
8
|
+
*/
|
|
9
|
+
export function requestSummary(request: ActionRequestSummary): string {
|
|
10
|
+
return `Request ${request.id ?? '?'} is ${request.lifecycle ?? 'unknown'}`
|
|
11
|
+
+ (request.effectCertainty ? ` (effect ${request.effectCertainty})` : '')
|
|
12
|
+
+ '.'
|
|
13
|
+
}
|
|
@@ -11,9 +11,9 @@ import { flagString, type Command } from '../types'
|
|
|
11
11
|
* armed path that every caller's request fails against looks identical to one
|
|
12
12
|
* nothing has tried yet. This is the only verb that can tell them apart.
|
|
13
13
|
*
|
|
14
|
-
* Read-only,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Read-only, so it runs on a key: listing gates on `actionRequest: ['read']`,
|
|
15
|
+
* which a key can hold. Invoking joins organization membership and needs a
|
|
16
|
+
* session — see `action submit`.
|
|
17
17
|
*/
|
|
18
18
|
export const actionRequests: Command = {
|
|
19
19
|
meta: {
|
|
@@ -83,8 +83,9 @@ export const actionRequests: Command = {
|
|
|
83
83
|
text: filtered
|
|
84
84
|
? `No requests with lifecycle "${filtered}".`
|
|
85
85
|
: 'No Action requests recorded.\n'
|
|
86
|
-
+ ' Nothing has invoked a deployed Action yet
|
|
87
|
-
+ '
|
|
86
|
+
+ ' Nothing has invoked a deployed Action yet. Raise one with\n'
|
|
87
|
+
+ ' `frontera action submit`, which acts as a person: set FRONTERA_TOKEN\n'
|
|
88
|
+
+ ' to your session token and FRONTERA_API_URL.',
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { GovernedActionApi, type ActionDiscovery } from '../../api/governed-action-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { toEnvelope } from '../../exit'
|
|
4
|
+
import { parseInputFlag } from '../automation/run'
|
|
5
|
+
import { flagString, type Command } from '../types'
|
|
6
|
+
import { requestSummary } from './request-summary'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Raise one request against a deployed Action — the step `action review`
|
|
10
|
+
* arms and `action requests` reads back.
|
|
11
|
+
*
|
|
12
|
+
* Session-lane: the invoke check joins organization and workspace membership,
|
|
13
|
+
* which no `wskey:` or `orgkey:` principal satisfies.
|
|
14
|
+
*
|
|
15
|
+
* The CLI assembles the envelope so the caller never writes it: parameters
|
|
16
|
+
* under `input`, the subject under `subjectRef` with the object type the
|
|
17
|
+
* Action itself declares, and the version under `expectedSubjectVersion`.
|
|
18
|
+
* The service refuses a wrong envelope without naming the field it disliked,
|
|
19
|
+
* so everything the discovery schema can check is checked here first.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `priority=2` is the number 2 and `title=Broken` the string — unless the
|
|
24
|
+
* Action declares the input a string, when the text is taken as written, so
|
|
25
|
+
* `ticketId=123` stays "123".
|
|
26
|
+
*/
|
|
27
|
+
function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown] {
|
|
28
|
+
const eq = raw.indexOf('=')
|
|
29
|
+
if (eq < 1) {
|
|
30
|
+
throw new UsageError(`"${raw}" is not a key=value parameter`, 'frontera action submit <apiName> title=Broken priority=2')
|
|
31
|
+
}
|
|
32
|
+
const key = raw.slice(0, eq)
|
|
33
|
+
const text = raw.slice(eq + 1)
|
|
34
|
+
if (stringInputs.has(key)) return [key, text]
|
|
35
|
+
try {
|
|
36
|
+
return [key, JSON.parse(text)]
|
|
37
|
+
} catch {
|
|
38
|
+
return [key, text]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const actionSubmit: Command = {
|
|
43
|
+
meta: {
|
|
44
|
+
noun: 'action',
|
|
45
|
+
verb: 'submit',
|
|
46
|
+
authLane: 'session',
|
|
47
|
+
scope: 'workspace' as const,
|
|
48
|
+
args: [
|
|
49
|
+
{ name: 'apiName', required: true, description: 'The Action to submit, as `action available` lists it' },
|
|
50
|
+
{ name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses' },
|
|
51
|
+
],
|
|
52
|
+
flags: {
|
|
53
|
+
input: 'string',
|
|
54
|
+
subject: 'string',
|
|
55
|
+
'subject-version': 'string',
|
|
56
|
+
reason: 'string',
|
|
57
|
+
'idempotency-key': 'string',
|
|
58
|
+
},
|
|
59
|
+
summary: 'Submit a request to a deployed Action',
|
|
60
|
+
examples: [
|
|
61
|
+
'frontera action submit createTicket title=Broken priority=2',
|
|
62
|
+
'frontera action submit escalateTicket --subject TKT-1 --subject-version 0',
|
|
63
|
+
'frontera action submit createTicket --input @params.json',
|
|
64
|
+
'frontera action submit createTicket --idempotency-key retry-2026-09-26-01',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async run(ctx) {
|
|
69
|
+
const [apiName, ...pairs] = ctx.positional
|
|
70
|
+
if (!apiName) {
|
|
71
|
+
throw new UsageError('missing <apiName>', 'frontera action available — to list the Actions you can submit')
|
|
72
|
+
}
|
|
73
|
+
const subject = flagString(ctx, 'subject')
|
|
74
|
+
const version = flagString(ctx, 'subject-version')
|
|
75
|
+
if ((subject === undefined) !== (version === undefined)) {
|
|
76
|
+
throw new UsageError(
|
|
77
|
+
'--subject and --subject-version go together',
|
|
78
|
+
`pass both: frontera action submit ${apiName} --subject <objectId> --subject-version <version>`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
83
|
+
// One read before the write: the schema is what lets a wrong envelope be
|
|
84
|
+
// refused here, by name, instead of by the service without one.
|
|
85
|
+
const action = (await client.discover()).find((a) => a.apiName === apiName)
|
|
86
|
+
if (!action) {
|
|
87
|
+
throw new CliError(`You cannot submit "${apiName}" in this workspace.`, {
|
|
88
|
+
code: 'NOT_FOUND',
|
|
89
|
+
hint: 'frontera action available — to list the Actions you can submit',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const input = {
|
|
94
|
+
...(await parseInputFlag(ctx)),
|
|
95
|
+
...Object.fromEntries(pairs.map((raw) => pair(raw, stringInputs(action)))),
|
|
96
|
+
}
|
|
97
|
+
const missing = (action.inputSchema?.properties?.input?.required ?? []).filter((name) => !(name in input))
|
|
98
|
+
if (missing.length > 0) {
|
|
99
|
+
throw new UsageError(
|
|
100
|
+
`"${apiName}" needs ${missing.join(', ')}`,
|
|
101
|
+
`frontera action submit ${apiName} ${missing.map((name) => `${name}=…`).join(' ')}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const invocation: Record<string, unknown> = { input }
|
|
106
|
+
const existing = action.subject?.mode === 'existing' && action.subject.objectTypeId
|
|
107
|
+
if (existing && subject === undefined) {
|
|
108
|
+
throw new UsageError(
|
|
109
|
+
`"${apiName}" acts on an existing object`,
|
|
110
|
+
`name it: frontera action submit ${apiName} --subject <objectId> --subject-version <version>`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (!existing && subject !== undefined) {
|
|
114
|
+
throw new UsageError(
|
|
115
|
+
`"${apiName}" does not act on an existing object`,
|
|
116
|
+
`drop --subject and --subject-version: frontera action submit ${apiName} key=value…`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (existing) {
|
|
120
|
+
// The object type is the Action's, not the caller's to choose.
|
|
121
|
+
invocation.subjectRef = { objectTypeId: action.subject!.objectTypeId, objectId: subject }
|
|
122
|
+
invocation.expectedSubjectVersion = version
|
|
123
|
+
}
|
|
124
|
+
const reason = flagString(ctx, 'reason')
|
|
125
|
+
if (reason !== undefined) invocation.reason = reason
|
|
126
|
+
else if (action.inputSchema?.required?.includes('reason')) {
|
|
127
|
+
throw new UsageError(`"${apiName}" requires a reason`, `frontera action submit ${apiName} … --reason "…"`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const idempotencyKey = flagString(ctx, 'idempotency-key') ?? `cli-${crypto.randomUUID()}`
|
|
131
|
+
if (!/^[!-~]{16,255}$/.test(idempotencyKey)) {
|
|
132
|
+
throw new UsageError(
|
|
133
|
+
'--idempotency-key must be 16–255 printable characters with no spaces',
|
|
134
|
+
'omit it to have one generated',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Announced BEFORE the request: the retry it enables is for exactly the
|
|
139
|
+
// case where no response ever arrives — a timeout, a dropped connection,
|
|
140
|
+
// Ctrl-C — and a key printed only on success is lost in all three.
|
|
141
|
+
ctx.output.note(`idempotency key ${idempotencyKey}`)
|
|
142
|
+
const retry = `retry with --idempotency-key ${idempotencyKey} to get the same request back, never a second one`
|
|
143
|
+
let request
|
|
144
|
+
try {
|
|
145
|
+
request = await client.submit(apiName, invocation, idempotencyKey)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Only where the outcome is unknown. A refusal the service answered
|
|
148
|
+
// (400, 403, 404, 409) is replayed identically under the same key, so
|
|
149
|
+
// naming the retry there would invite a loop; it is rethrown as is.
|
|
150
|
+
if (!transient(err)) throw err
|
|
151
|
+
const envelope = toEnvelope(err)
|
|
152
|
+
throw new CliError(envelope.message, {
|
|
153
|
+
code: err instanceof CliError ? envelope.code : 'FAILURE',
|
|
154
|
+
hint: envelope.hint ? `${envelope.hint}; ${retry}` : retry,
|
|
155
|
+
cause: err,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
data: { request, idempotencyKey },
|
|
160
|
+
text: `${requestSummary(request)}\n`
|
|
161
|
+
+ ` idempotency key ${idempotencyKey} — reuse it with --idempotency-key to retry safely\n`
|
|
162
|
+
+ ` frontera action requests ${request.id ?? '<requestId>'}`,
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* No answer, or one that says "not now": a network error or timeout (anything
|
|
169
|
+
* that is not a CliError never reached a response), a 5xx, or a 429.
|
|
170
|
+
*/
|
|
171
|
+
function transient(err: unknown): boolean {
|
|
172
|
+
if (!(err instanceof CliError)) return true
|
|
173
|
+
const status = (err.cause as { status?: unknown } | undefined)?.status
|
|
174
|
+
return typeof status === 'number' && (status >= 500 || status === 429)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Inputs the Action declares as strings, whose text must not be read as JSON. */
|
|
178
|
+
function stringInputs(action: ActionDiscovery): ReadonlySet<string> {
|
|
179
|
+
const properties = action.inputSchema?.properties?.input?.properties ?? {}
|
|
180
|
+
return new Set(Object.entries(properties)
|
|
181
|
+
.filter(([, schema]) => (schema as { type?: unknown } | null)?.type === 'string')
|
|
182
|
+
.map(([name]) => name))
|
|
183
|
+
}
|
|
@@ -29,6 +29,57 @@ interface ObjectTypeRow {
|
|
|
29
29
|
description?: string | null
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
interface Relation {
|
|
33
|
+
apiName?: string
|
|
34
|
+
direction: 'outbound' | 'inbound'
|
|
35
|
+
otherObjectType: string
|
|
36
|
+
cardinality?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One catalog read: the rows, or why they are missing.
|
|
41
|
+
*
|
|
42
|
+
* `unavailable` is not an error path — the command still prints. It is the
|
|
43
|
+
* difference between "this type has no relations" and "nobody could tell",
|
|
44
|
+
* which a bare `[]` erased.
|
|
45
|
+
*/
|
|
46
|
+
type CatalogRead<T> =
|
|
47
|
+
| { ok: true; rows: T[] }
|
|
48
|
+
| { ok: false; unavailable: string }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Describe a failed read in the terms the caller can act on.
|
|
52
|
+
*
|
|
53
|
+
* The message is carried as well as the status and code, because the two say
|
|
54
|
+
* different things and only one of them is ever actionable: `403 FORBIDDEN`
|
|
55
|
+
* alone hides "Blueprint Steward assignment required", which is the sentence
|
|
56
|
+
* that tells the operator what to go and do. `detail` is appended where the
|
|
57
|
+
* service sent one — `org-key-route-ungated` is the case where no grant change
|
|
58
|
+
* will help, and saying so stops the reader hunting for a permission to add.
|
|
59
|
+
*/
|
|
60
|
+
function describeFailure(cause: unknown): string {
|
|
61
|
+
const error = cause as
|
|
62
|
+
| { code?: string; status?: number; message?: string; details?: { detail?: string } }
|
|
63
|
+
| null
|
|
64
|
+
const status = [error?.status ? String(error.status) : null, error?.code ?? null]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(' ')
|
|
67
|
+
const parts = [
|
|
68
|
+
status || null,
|
|
69
|
+
error?.message ?? null,
|
|
70
|
+
error?.details?.detail ?? null,
|
|
71
|
+
].filter(Boolean)
|
|
72
|
+
return parts.length > 0 ? parts.join(' — ') : String(cause)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCatalog<T>(fetch: () => Promise<T[]>): Promise<CatalogRead<T>> {
|
|
76
|
+
try {
|
|
77
|
+
return { ok: true, rows: await fetch() }
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
return { ok: false, unavailable: describeFailure(cause) }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
/**
|
|
33
84
|
* Resolve what the caller typed to a real api name.
|
|
34
85
|
*
|
|
@@ -99,29 +150,70 @@ export const blueprintGet: Command = {
|
|
|
99
150
|
// Both key on object-type UUIDs, not api names, so the catalog is fetched
|
|
100
151
|
// to resolve them. Matching on names silently produced "Relations (0)" for
|
|
101
152
|
// a type with eight of them.
|
|
153
|
+
//
|
|
154
|
+
// Degrade rather than crash — one unreadable catalog route should not stop
|
|
155
|
+
// the command printing the properties it already has. But a failure is
|
|
156
|
+
// reported as a failure: an empty list and an unreadable one are different
|
|
157
|
+
// facts, and rendering both as "(none)" told the caller a type had no
|
|
158
|
+
// relations when the truth was that nothing could check.
|
|
102
159
|
const [links, metrics, types] = await Promise.all([
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
160
|
+
readCatalog(() => api.blueprintLinkTypes()),
|
|
161
|
+
readCatalog(() => api.blueprintMetrics()),
|
|
162
|
+
readCatalog(() => api.blueprintObjectTypes()),
|
|
106
163
|
])
|
|
107
164
|
|
|
165
|
+
// Relations and metrics key on object-type UUIDs, so both depend on the
|
|
166
|
+
// object-type list to resolve an id to a name. If THAT read failed there is
|
|
167
|
+
// no id to match on, and every link would silently drop — so its failure
|
|
168
|
+
// makes both sections unavailable, not empty.
|
|
169
|
+
const typeRows = types.ok
|
|
170
|
+
? (types.rows as Array<{ id?: string; apiName?: string }>)
|
|
171
|
+
: []
|
|
108
172
|
const nameById = new Map<string, string>()
|
|
109
|
-
for (const
|
|
110
|
-
if (
|
|
173
|
+
for (const row of typeRows) {
|
|
174
|
+
if (row.id && row.apiName) nameById.set(row.id, row.apiName)
|
|
111
175
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
// `apiName` was resolved against `/schema` (the workspace-granted slice) while
|
|
177
|
+
// the id comes from `/object-types` (the organization catalog) — two sources,
|
|
178
|
+
// so a type can be present in the first and missing from the second even when
|
|
179
|
+
// BOTH reads succeeded. Without this, `selfId` fell back to `''`, matched no
|
|
180
|
+
// link, and printed the same confident `Relations (0)` this command exists to
|
|
181
|
+
// stop. A read that worked but cannot answer is still "cannot answer".
|
|
182
|
+
const selfRow = typeRows.find((candidate) => candidate.apiName === apiName)
|
|
183
|
+
const selfId = selfRow?.id ?? ''
|
|
184
|
+
const unresolvedSelf = types.ok && !selfId
|
|
185
|
+
? `could not resolve ${apiName} in the object-type catalog — it is in this workspace's schema but absent from the catalog list`
|
|
186
|
+
: null
|
|
187
|
+
|
|
188
|
+
const relations: CatalogRead<Relation> = !types.ok
|
|
189
|
+
? { ok: false, unavailable: `could not read object types: ${types.unavailable}` }
|
|
190
|
+
: unresolvedSelf
|
|
191
|
+
? { ok: false, unavailable: unresolvedSelf }
|
|
192
|
+
: !links.ok
|
|
193
|
+
? { ok: false, unavailable: `could not read link types: ${links.unavailable}` }
|
|
194
|
+
: {
|
|
195
|
+
ok: true,
|
|
196
|
+
rows: (links.rows as Link[]).flatMap((link) => {
|
|
197
|
+
if (link.fromObjectTypeId !== selfId && link.toObjectTypeId !== selfId) return []
|
|
198
|
+
const outbound = link.fromObjectTypeId === selfId
|
|
199
|
+
const other = nameById.get((outbound ? link.toObjectTypeId : link.fromObjectTypeId) ?? '')
|
|
200
|
+
if (!other || !grantedNames.has(other)) return []
|
|
201
|
+
return [{ apiName: link.apiName, direction: outbound ? 'outbound' as const : 'inbound' as const, otherObjectType: other, cardinality: link.cardinality }]
|
|
202
|
+
}),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const own: CatalogRead<{ apiName?: string; displayName?: string }> = !types.ok
|
|
206
|
+
? { ok: false, unavailable: `could not read object types: ${types.unavailable}` }
|
|
207
|
+
: unresolvedSelf
|
|
208
|
+
? { ok: false, unavailable: unresolvedSelf }
|
|
209
|
+
: !metrics.ok
|
|
210
|
+
? { ok: false, unavailable: `could not read metrics: ${metrics.unavailable}` }
|
|
211
|
+
: {
|
|
212
|
+
ok: true,
|
|
213
|
+
rows: (metrics.rows as Metric[])
|
|
214
|
+
.filter((metric) => metric.objectTypeId === selfId)
|
|
215
|
+
.map((metric) => ({ apiName: metric.apiName, displayName: metric.displayName })),
|
|
216
|
+
}
|
|
125
217
|
|
|
126
218
|
const props = type.properties as Property[]
|
|
127
219
|
const heading = [apiName, type.displayName].filter(Boolean)
|
|
@@ -137,21 +229,39 @@ export const blueprintGet: Command = {
|
|
|
137
229
|
` ${(p.apiName ?? '?').padEnd(26)}${(p.dataType ?? '').padEnd(12)}${p.propertyType ?? ''}`,
|
|
138
230
|
)),
|
|
139
231
|
'',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
232
|
+
...(relations.ok
|
|
233
|
+
? [
|
|
234
|
+
`Relations (${relations.rows.length})`,
|
|
235
|
+
...(relations.rows.length === 0
|
|
236
|
+
? [' (none)']
|
|
237
|
+
: relations.rows.map((relation) => {
|
|
238
|
+
return ` ${(relation.apiName ?? '?').padEnd(26)}${relation.direction === 'outbound' ? '→' : '←'} ${relation.otherObjectType.padEnd(18)}${relation.cardinality ?? ''}`
|
|
239
|
+
})),
|
|
240
|
+
]
|
|
241
|
+
: ['Relations (unavailable)', ` ${relations.unavailable}`]),
|
|
146
242
|
'',
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
243
|
+
...(own.ok
|
|
244
|
+
? [
|
|
245
|
+
`Metrics (${own.rows.length})`,
|
|
246
|
+
...(own.rows.length === 0
|
|
247
|
+
? [' (none)']
|
|
248
|
+
: own.rows.map((m) => ` ${(m.apiName ?? '?').padEnd(26)}${m.displayName ?? ''}`)),
|
|
249
|
+
]
|
|
250
|
+
: ['Metrics (unavailable)', ` ${own.unavailable}`]),
|
|
151
251
|
]
|
|
152
252
|
|
|
153
253
|
return {
|
|
154
|
-
data: {
|
|
254
|
+
data: {
|
|
255
|
+
objectType: type,
|
|
256
|
+
properties: props,
|
|
257
|
+
// `null` rather than `[]` for an unreadable section, and a sibling
|
|
258
|
+
// field saying why. A consumer that treats absence as emptiness now has
|
|
259
|
+
// to opt into it; the old `[]` gave it no choice.
|
|
260
|
+
relations: relations.ok ? relations.rows : null,
|
|
261
|
+
...(relations.ok ? {} : { relationsUnavailable: relations.unavailable }),
|
|
262
|
+
metrics: own.ok ? own.rows : null,
|
|
263
|
+
...(own.ok ? {} : { metricsUnavailable: own.unavailable }),
|
|
264
|
+
},
|
|
155
265
|
text: lines.join('\n'),
|
|
156
266
|
}
|
|
157
267
|
},
|