@crombolo/assistant 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +37 -4
- package/src/apply.ts +144 -0
- package/src/changeset.ts +120 -0
- package/src/describe.ts +209 -0
- package/src/guardrails.ts +187 -0
- package/src/index.ts +12 -0
- package/src/loop.ts +81 -0
- package/src/mcp.ts +97 -0
- package/src/operations.ts +192 -0
- package/src/prompt.ts +39 -0
- package/src/provider.ts +77 -0
- package/src/providers/bedrock.ts +269 -0
- package/src/review.ts +205 -0
- package/src/tools.ts +522 -0
- package/README.md +0 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bedrock, through the Converse API.
|
|
3
|
+
*
|
|
4
|
+
* The AWS SDK does the signing and the credential resolution. That is a
|
|
5
|
+
* dependency this package otherwise does without, and it is here for one reason:
|
|
6
|
+
* credentials. A developer's AWS access is an SSO profile rather than a pair of
|
|
7
|
+
* long-lived keys, and resolving one means the shared config file,
|
|
8
|
+
* `source_profile` chains, the SSO token cache and its refresh — most of why the
|
|
9
|
+
* SDK is large, and not something to reimplement. Resolving credentials alone
|
|
10
|
+
* costs 13 MB against the full client's 14 MB, so the client comes too and the
|
|
11
|
+
* hand-rolled SigV4 goes.
|
|
12
|
+
*
|
|
13
|
+
* `AWS_PROFILE` and the rest of the chain then work as they do for every other AWS
|
|
14
|
+
* tool: options first, then the environment, the shared config, SSO, and finally
|
|
15
|
+
* an instance or task role.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
BedrockRuntimeClient,
|
|
19
|
+
type ContentBlock,
|
|
20
|
+
ConverseCommand,
|
|
21
|
+
type Message,
|
|
22
|
+
} from '@aws-sdk/client-bedrock-runtime'
|
|
23
|
+
import { defaultProvider } from '@aws-sdk/credential-provider-node'
|
|
24
|
+
import { NodeHttpHandler } from '@smithy/node-http-handler'
|
|
25
|
+
import type { DocumentType } from '@smithy/types'
|
|
26
|
+
import type {
|
|
27
|
+
AssistantProvider,
|
|
28
|
+
ConverseInput,
|
|
29
|
+
ConverseResult,
|
|
30
|
+
MessagePart,
|
|
31
|
+
ProviderMessage,
|
|
32
|
+
ProviderStatus,
|
|
33
|
+
} from '../provider.ts'
|
|
34
|
+
|
|
35
|
+
export interface BedrockOptions {
|
|
36
|
+
/** A model id or inference profile id, e.g. `apac.anthropic.claude-…`. */
|
|
37
|
+
model: string
|
|
38
|
+
region?: string
|
|
39
|
+
/** A named profile from the shared config, as `AWS_PROFILE` would select. */
|
|
40
|
+
profile?: string
|
|
41
|
+
/** Static credentials, for a site that holds them somewhere of its own. */
|
|
42
|
+
accessKeyId?: string
|
|
43
|
+
secretAccessKey?: string
|
|
44
|
+
sessionToken?: string
|
|
45
|
+
/** Overrides the endpoint, for a VPC endpoint or a test double. */
|
|
46
|
+
endpoint?: string
|
|
47
|
+
maxTokens?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An option or variable that was given; a container passes an unset one as `''`. */
|
|
51
|
+
const given = (...values: (string | undefined)[]): string | undefined =>
|
|
52
|
+
values.find((value) => value !== undefined && value !== '')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The credential chain ends at the instance metadata endpoint, which does not
|
|
56
|
+
* answer on a laptop or in a container that has no route to it — and waits a long
|
|
57
|
+
* time about it: a profile that cannot be resolved took two and a half minutes to
|
|
58
|
+
* say so. A check that reports late is a check nobody waits for, so it is bounded.
|
|
59
|
+
*/
|
|
60
|
+
const CHECK_TIMEOUT_MS = 10_000
|
|
61
|
+
|
|
62
|
+
/** Small enough to be nearly free, large enough that no model calls it invalid. */
|
|
63
|
+
const CHECK_MAX_TOKENS = 16
|
|
64
|
+
|
|
65
|
+
function withTimeout<T>(work: Promise<T>, ms: number, message: string): Promise<T> {
|
|
66
|
+
return new Promise<T>((resolve, reject) => {
|
|
67
|
+
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
68
|
+
timer.unref?.()
|
|
69
|
+
work.then(resolve, reject).finally(() => clearTimeout(timer))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Turns a Bedrock refusal into something worth acting on. */
|
|
74
|
+
function modelRemedy(error: Error, model: string, region: string): string {
|
|
75
|
+
const name = error.name
|
|
76
|
+
if (name === 'AccessDeniedException') {
|
|
77
|
+
return `Grant this account access to ${model} in the Bedrock console for ${region}.`
|
|
78
|
+
}
|
|
79
|
+
if (name === 'ValidationException' || name === 'ResourceNotFoundException') {
|
|
80
|
+
return `Check the model id: ${model} may not exist in ${region}, and most models need a regional or global inference profile id — au.…, apac.… or global.… — rather than a bare id.`
|
|
81
|
+
}
|
|
82
|
+
return `Check that ${model} is available to this account in ${region}.`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toContent(part: MessagePart): ContentBlock {
|
|
86
|
+
if (part.kind === 'text') return { text: part.text }
|
|
87
|
+
if (part.kind === 'tool-use') {
|
|
88
|
+
// Both sides came from JSON, which is what a smithy document is.
|
|
89
|
+
return { toolUse: { toolUseId: part.id, name: part.name, input: part.input as DocumentType } }
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
toolResult: {
|
|
93
|
+
toolUseId: part.id,
|
|
94
|
+
content: [{ json: asJsonObject(part.content) }],
|
|
95
|
+
status: part.ok ? 'success' : 'error',
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Converse requires `toolResult.content[].json` to be a JSON *object*, and
|
|
102
|
+
* refuses an array or a scalar with "the format of the value … is invalid".
|
|
103
|
+
* Tools are free to answer with a list — `list_operations` does — so anything
|
|
104
|
+
* that is not an object is wrapped rather than rejected.
|
|
105
|
+
*/
|
|
106
|
+
function asJsonObject(value: unknown): DocumentType {
|
|
107
|
+
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
108
|
+
return (isObject ? value : { result: value ?? null }) as DocumentType
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const toMessage = (message: ProviderMessage): Message => ({
|
|
112
|
+
role: message.role,
|
|
113
|
+
content: message.parts.map(toContent),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
export function bedrock(options: BedrockOptions): AssistantProvider {
|
|
117
|
+
const region = given(options.region, Bun.env.AWS_REGION) ?? 'us-east-1'
|
|
118
|
+
const profile = given(options.profile, Bun.env.AWS_PROFILE)
|
|
119
|
+
const accessKeyId = given(options.accessKeyId, Bun.env.AWS_ACCESS_KEY_ID)
|
|
120
|
+
const secretAccessKey = given(options.secretAccessKey, Bun.env.AWS_SECRET_ACCESS_KEY)
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Static credentials when the site holds them, otherwise the standard chain.
|
|
124
|
+
* Built once, so a resolved SSO token is cached and refreshed rather than read
|
|
125
|
+
* from disk on every request.
|
|
126
|
+
*/
|
|
127
|
+
const credentials =
|
|
128
|
+
accessKeyId && secretAccessKey
|
|
129
|
+
? { accessKeyId, secretAccessKey, sessionToken: given(options.sessionToken) }
|
|
130
|
+
: defaultProvider(profile ? { profile } : {})
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Built lazily, so a missing or expired profile can never stop the site
|
|
134
|
+
* booting: the assistant is optional and reports its own state instead.
|
|
135
|
+
*/
|
|
136
|
+
let client: BedrockRuntimeClient | undefined
|
|
137
|
+
const connection = () => {
|
|
138
|
+
client ??= new BedrockRuntimeClient({
|
|
139
|
+
region,
|
|
140
|
+
credentials,
|
|
141
|
+
...(given(options.endpoint) ? { endpoint: options.endpoint } : {}),
|
|
142
|
+
// HTTP/1.1. The client defaults to HTTP/2 for the streaming operations and
|
|
143
|
+
// Converse does not stream, so this keeps it off Bun's h2 client entirely.
|
|
144
|
+
requestHandler: new NodeHttpHandler(),
|
|
145
|
+
})
|
|
146
|
+
return client
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Follows the precedence above rather than guessing it: static keys win over a
|
|
150
|
+
// profile for the credentials, so they must win for the label too, or a status
|
|
151
|
+
// line names a profile that had nothing to do with the call.
|
|
152
|
+
const source =
|
|
153
|
+
typeof credentials === 'function'
|
|
154
|
+
? profile
|
|
155
|
+
? `profile ${profile}`
|
|
156
|
+
: 'the environment'
|
|
157
|
+
: 'static credentials'
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
name: `bedrock:${options.model}`,
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether credentials resolve — not whether the model is reachable or access
|
|
164
|
+
* to it has been granted, which only a real call would show and which would
|
|
165
|
+
* cost tokens to ask on every boot.
|
|
166
|
+
*/
|
|
167
|
+
async check(deep = false): Promise<ProviderStatus> {
|
|
168
|
+
const where = `${options.model} in ${region}, using ${source}`
|
|
169
|
+
let expires: string | undefined
|
|
170
|
+
try {
|
|
171
|
+
const resolved = await withTimeout(
|
|
172
|
+
typeof credentials === 'function' ? credentials() : Promise.resolve(credentials),
|
|
173
|
+
CHECK_TIMEOUT_MS,
|
|
174
|
+
'Timed out resolving AWS credentials.',
|
|
175
|
+
)
|
|
176
|
+
const expiration = (resolved as { expiration?: Date }).expiration
|
|
177
|
+
expires = expiration ? expiration.toISOString() : undefined
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
detail: (error as Error).message,
|
|
182
|
+
remedy: profile
|
|
183
|
+
? `Run \`aws sso login --profile ${profile}\`, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.`
|
|
184
|
+
: 'Set AWS_PROFILE, or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!deep) return { ok: true, detail: where, expires, model: 'unchecked' }
|
|
189
|
+
|
|
190
|
+
// Credentials resolving says nothing about whether this account may invoke
|
|
191
|
+
// this model in this region. Only a real call does, so it makes close to the
|
|
192
|
+
// smallest one there is: a word in, a few tokens out.
|
|
193
|
+
//
|
|
194
|
+
// Not one token out: some models refuse a `maxTokens` that low —
|
|
195
|
+
// `global.xai.grok-4.7` answers `integer_below_min_value` — and a check that
|
|
196
|
+
// reports a working model as broken is worse than one that costs a token or
|
|
197
|
+
// two more.
|
|
198
|
+
try {
|
|
199
|
+
await withTimeout(
|
|
200
|
+
connection().send(
|
|
201
|
+
new ConverseCommand({
|
|
202
|
+
modelId: options.model,
|
|
203
|
+
messages: [{ role: 'user', content: [{ text: 'ping' }] }],
|
|
204
|
+
inferenceConfig: { maxTokens: CHECK_MAX_TOKENS },
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
CHECK_TIMEOUT_MS,
|
|
208
|
+
`Timed out asking Bedrock for ${options.model}.`,
|
|
209
|
+
)
|
|
210
|
+
return { ok: true, detail: `${where}, model reachable`, expires, model: 'ok' }
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const failure = error as Error
|
|
213
|
+
// Being throttled means the call got through: credentials and access are
|
|
214
|
+
// both fine, which is what this is asking.
|
|
215
|
+
if (failure.name === 'ThrottlingException') {
|
|
216
|
+
return { ok: true, detail: `${where}, throttled but reachable`, expires, model: 'ok' }
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
detail: `${options.model} did not answer: ${failure.message}`,
|
|
221
|
+
remedy: modelRemedy(failure, options.model, region),
|
|
222
|
+
expires,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async converse(input: ConverseInput): Promise<ConverseResult> {
|
|
228
|
+
const response = await connection().send(
|
|
229
|
+
new ConverseCommand({
|
|
230
|
+
modelId: options.model,
|
|
231
|
+
system: input.system === '' ? undefined : [{ text: input.system }],
|
|
232
|
+
messages: input.messages.map(toMessage),
|
|
233
|
+
inferenceConfig: { maxTokens: input.maxTokens ?? options.maxTokens ?? 4096 },
|
|
234
|
+
toolConfig:
|
|
235
|
+
input.tools.length === 0
|
|
236
|
+
? undefined
|
|
237
|
+
: {
|
|
238
|
+
tools: input.tools.map((tool) => ({
|
|
239
|
+
toolSpec: {
|
|
240
|
+
name: tool.name,
|
|
241
|
+
description: tool.description,
|
|
242
|
+
inputSchema: { json: tool.inputSchema as DocumentType },
|
|
243
|
+
},
|
|
244
|
+
})),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const parts: MessagePart[] = []
|
|
250
|
+
for (const item of response.output?.message?.content ?? []) {
|
|
251
|
+
if (typeof item.text === 'string' && item.text !== '') {
|
|
252
|
+
parts.push({ kind: 'text', text: item.text })
|
|
253
|
+
}
|
|
254
|
+
if (item.toolUse?.toolUseId && item.toolUse.name) {
|
|
255
|
+
parts.push({
|
|
256
|
+
kind: 'tool-use',
|
|
257
|
+
id: item.toolUse.toolUseId,
|
|
258
|
+
name: item.toolUse.name,
|
|
259
|
+
input: (item.toolUse.input as Record<string, unknown>) ?? {},
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
message: { role: 'assistant', parts },
|
|
265
|
+
wantsTools: response.stopReason === 'tool_use',
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a reviewer sees before approving: the proposal against what is there now.
|
|
3
|
+
*
|
|
4
|
+
* The changeset stores the prepared request, not a snapshot of the old values, so
|
|
5
|
+
* the "before" side is read at review time. That is the right way round — it means
|
|
6
|
+
* the diff shown is against the entity as it stands when someone looks at it, not
|
|
7
|
+
* as it stood when the model was talking, and a proposal whose baseline has moved
|
|
8
|
+
* says so here rather than surprising them after they click Approve.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProposedChange, SchemaProposalPort } from './changeset.ts'
|
|
11
|
+
import { type ChangeKind, SCHEMA_KINDS } from './operations.ts'
|
|
12
|
+
import type { ManagementCall } from './tools.ts'
|
|
13
|
+
|
|
14
|
+
export interface FieldChange {
|
|
15
|
+
/** The property alias, or a field name for a type. */
|
|
16
|
+
name: string
|
|
17
|
+
/** Absent for a culture-invariant value. */
|
|
18
|
+
culture?: string | null
|
|
19
|
+
before: unknown
|
|
20
|
+
after: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChangeReview {
|
|
24
|
+
kind: ChangeKind
|
|
25
|
+
/** True when nothing exists yet, so every field is new. */
|
|
26
|
+
creating: boolean
|
|
27
|
+
fields: FieldChange[]
|
|
28
|
+
/** Whole-text diff sides, for a template. */
|
|
29
|
+
source?: { before: string; after: string }
|
|
30
|
+
/** Set when the entity moved after the proposal was made. */
|
|
31
|
+
stale: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Which read gives the current state, and the part of it that matters. */
|
|
35
|
+
const CURRENT: Partial<Record<ChangeKind, string>> = {
|
|
36
|
+
document: 'GetDocumentById',
|
|
37
|
+
'document-type': 'GetDocumentTypeById',
|
|
38
|
+
'data-type': 'GetDataTypeById',
|
|
39
|
+
template: 'GetTemplateById',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const digest = (value: unknown): string =>
|
|
43
|
+
new Bun.CryptoHasher('sha256').update(JSON.stringify(value ?? null)).digest('hex')
|
|
44
|
+
|
|
45
|
+
interface ValueEntry {
|
|
46
|
+
alias: string
|
|
47
|
+
value?: unknown
|
|
48
|
+
culture?: string | null
|
|
49
|
+
segment?: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface VariantEntry {
|
|
53
|
+
culture?: string | null
|
|
54
|
+
segment?: string | null
|
|
55
|
+
name?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
59
|
+
|
|
60
|
+
const variantKey = (variant: VariantEntry) => `${variant.culture ?? ''}|${variant.segment ?? ''}`
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Renames, as fields, so they appear in the list a reviewer reads.
|
|
64
|
+
*
|
|
65
|
+
* A document proposal carries the whole `variants` array because the update
|
|
66
|
+
* handler needs it, which means a rename can ride along inside what looks like a
|
|
67
|
+
* value edit. Compared per culture and segment, so a page that simply has
|
|
68
|
+
* different names in different languages is not reported as renamed.
|
|
69
|
+
*/
|
|
70
|
+
export function variantChanges(
|
|
71
|
+
before: readonly VariantEntry[],
|
|
72
|
+
after: readonly VariantEntry[],
|
|
73
|
+
): FieldChange[] {
|
|
74
|
+
const was = new Map(before.map((variant) => [variantKey(variant), variant.name ?? '']))
|
|
75
|
+
const changes: FieldChange[] = []
|
|
76
|
+
for (const variant of after) {
|
|
77
|
+
const previous = was.get(variantKey(variant))
|
|
78
|
+
const name = variant.name ?? ''
|
|
79
|
+
if (previous === undefined || previous === name) continue
|
|
80
|
+
changes.push({ name: 'Name', culture: variant.culture ?? null, before: previous, after: name })
|
|
81
|
+
}
|
|
82
|
+
return changes
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Values that differ, in the proposal's order, with anything untouched left out. */
|
|
86
|
+
function valueChanges(before: readonly ValueEntry[], after: readonly ValueEntry[]): FieldChange[] {
|
|
87
|
+
const changes: FieldChange[] = []
|
|
88
|
+
for (const entry of after) {
|
|
89
|
+
const previous = before.find(
|
|
90
|
+
(candidate) =>
|
|
91
|
+
candidate.alias === entry.alias &&
|
|
92
|
+
(candidate.culture ?? null) === (entry.culture ?? null) &&
|
|
93
|
+
(candidate.segment ?? null) === (entry.segment ?? null),
|
|
94
|
+
)
|
|
95
|
+
if (same(previous?.value, entry.value)) continue
|
|
96
|
+
changes.push({
|
|
97
|
+
name: entry.alias,
|
|
98
|
+
culture: entry.culture ?? null,
|
|
99
|
+
before: previous?.value,
|
|
100
|
+
after: entry.value,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
return changes
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fields of a type that differ. Compared as JSON at one level, which reads well
|
|
108
|
+
* for the scalars that matter (name, alias, icon) and honestly for the lists
|
|
109
|
+
* (properties, compositions) without pretending to a structural diff.
|
|
110
|
+
*/
|
|
111
|
+
function bodyChanges(before: unknown, after: unknown): FieldChange[] {
|
|
112
|
+
const previous = (before ?? {}) as Record<string, unknown>
|
|
113
|
+
const proposed = (after ?? {}) as Record<string, unknown>
|
|
114
|
+
const changes: FieldChange[] = []
|
|
115
|
+
for (const [name, value] of Object.entries(proposed)) {
|
|
116
|
+
if (same(previous[name], value)) continue
|
|
117
|
+
changes.push({ name, before: previous[name], after: value })
|
|
118
|
+
}
|
|
119
|
+
return changes
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function reviewChange(
|
|
123
|
+
change: ProposedChange,
|
|
124
|
+
options: {
|
|
125
|
+
call: ManagementCall
|
|
126
|
+
operationPath: (operationId: string) => string | undefined
|
|
127
|
+
/** Reads the schema file a TOML proposal would replace. */
|
|
128
|
+
schema?: SchemaProposalPort
|
|
129
|
+
},
|
|
130
|
+
): Promise<ChangeReview> {
|
|
131
|
+
const creating = change.kind.endsWith('-create')
|
|
132
|
+
const body = (change.body ?? {}) as Record<string, unknown>
|
|
133
|
+
|
|
134
|
+
// A schema proposal is a file, so it reads as one: the TOML on disk beside the
|
|
135
|
+
// TOML that would replace it.
|
|
136
|
+
if (SCHEMA_KINDS.has(change.kind)) {
|
|
137
|
+
const alias = typeof body.alias === 'string' ? body.alias : ''
|
|
138
|
+
const before = (await options.schema?.read(change.kind, alias)) ?? ''
|
|
139
|
+
const after = typeof body.toml === 'string' ? body.toml : ''
|
|
140
|
+
return {
|
|
141
|
+
kind: change.kind,
|
|
142
|
+
creating,
|
|
143
|
+
fields: [],
|
|
144
|
+
source: { before, after },
|
|
145
|
+
stale: change.baseline !== undefined && digest(before) !== change.baseline,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (creating) {
|
|
150
|
+
const fields =
|
|
151
|
+
change.kind === 'document-create'
|
|
152
|
+
? valueChanges([], (body.values as ValueEntry[]) ?? [])
|
|
153
|
+
: bodyChanges(
|
|
154
|
+
{},
|
|
155
|
+
change.kind === 'template-create' ? { ...body, content: undefined } : body,
|
|
156
|
+
)
|
|
157
|
+
const review: ChangeReview = { kind: change.kind, creating: true, fields, stale: false }
|
|
158
|
+
if (change.kind === 'template-create') {
|
|
159
|
+
review.source = { before: '', after: String(body.content ?? '') }
|
|
160
|
+
}
|
|
161
|
+
return review
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const operationId = CURRENT[change.kind]
|
|
165
|
+
const template = operationId ? options.operationPath(operationId) : undefined
|
|
166
|
+
if (!operationId || !template) {
|
|
167
|
+
return { kind: change.kind, creating: false, fields: [], stale: false }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const path = template.replace('{id}', encodeURIComponent(change.params.id ?? ''))
|
|
171
|
+
const current = await options.call({ method: 'get', path })
|
|
172
|
+
if (current.status !== 200) {
|
|
173
|
+
return { kind: change.kind, creating: false, fields: [], stale: true }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (change.kind === 'template') {
|
|
177
|
+
const before = String((current.body as { content?: string | null }).content ?? '')
|
|
178
|
+
return {
|
|
179
|
+
kind: change.kind,
|
|
180
|
+
creating: false,
|
|
181
|
+
fields: [],
|
|
182
|
+
source: { before, after: String(body.content ?? '') },
|
|
183
|
+
stale: change.baseline !== undefined && digest(before) !== change.baseline,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const stale = change.baseline !== undefined && digest(current.body) !== change.baseline
|
|
188
|
+
|
|
189
|
+
if (change.kind === 'document') {
|
|
190
|
+
const now = current.body as { values?: ValueEntry[]; variants?: VariantEntry[] }
|
|
191
|
+
return {
|
|
192
|
+
kind: change.kind,
|
|
193
|
+
creating: false,
|
|
194
|
+
fields: [
|
|
195
|
+
// Renames first: a page's name is not a property value, so without this
|
|
196
|
+
// the diff a reviewer approves would not show one at all.
|
|
197
|
+
...variantChanges(now.variants ?? [], (body.variants as VariantEntry[]) ?? []),
|
|
198
|
+
...valueChanges(now.values ?? [], (body.values as ValueEntry[]) ?? []),
|
|
199
|
+
],
|
|
200
|
+
stale,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { kind: change.kind, creating: false, fields: bodyChanges(current.body, body), stale }
|
|
205
|
+
}
|