@frontera-sdk/cli 1.53.5 → 1.53.7

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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
@@ -1,5 +1,7 @@
1
1
  import { AppsApi } from '../../api/apps-api'
2
+ import { PlatformApi } from '../../api/platform-api'
2
3
  import { UsageError } from '../../errors'
4
+ import { appBinding, appTarget, bindApp } from '../../project'
3
5
  import type { AppProject } from '../../context'
4
6
  import type { CommandContext } from '../types'
5
7
 
@@ -13,6 +15,16 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
13
15
  return ctx.project
14
16
  }
15
17
 
18
+ /**
19
+ * The target this credential reaches (`appTarget`): its workspace as `whoami`
20
+ * answers, asked the way `AppsApi` asks, so it is the workspace the App calls
21
+ * land in. Null when it cannot say; the folder's bindings are then left alone.
22
+ */
23
+ export async function credentialTarget(ctx: CommandContext): Promise<string | null> {
24
+ const me = await new PlatformApi(ctx.apiUrl, ctx.token).whoami().catch(() => null)
25
+ return me?.workspaceId ? appTarget(ctx.apiUrl, me.workspaceId) : null
26
+ }
27
+
16
28
  /**
17
29
  * The app this project belongs to, without creating one.
18
30
  *
@@ -20,6 +32,51 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
20
32
  * that ensured instead would turn a typo, or a slug the platform had since
21
33
  * renamed, into a brand new empty app.
22
34
  */
23
- export function projectApp(client: AppsApi, project: AppProject) {
24
- return client.resolveApp(project.appId ?? project.slug)
35
+ export async function projectApp(ctx: CommandContext, client: AppsApi, project: AppProject) {
36
+ const bound = appBinding(project.root, await credentialTarget(ctx)).id
37
+ return client.resolveApp(bound ?? project.fallbackAppId ?? project.slug)
38
+ }
39
+
40
+ /**
41
+ * The folder's App where the credential acts, created when it has none there
42
+ * (`save`, `deploy`, `app dev`), and bound to the folder for that target. A
43
+ * creation is said: a person who renamed the folder or moved it to another
44
+ * workspace must not learn of a second App from the gallery. `app` names an
45
+ * existing App to deploy to instead, which never creates one.
46
+ */
47
+ export async function ensureProjectApp(
48
+ ctx: CommandContext,
49
+ client: AppsApi,
50
+ project: AppProject,
51
+ app?: string,
52
+ ): Promise<{ id: string; slug: string; created: string | null; target: string | null }> {
53
+ const target = await credentialTarget(ctx)
54
+ const known = appBinding(project.root, target)
55
+ const named = app ? await client.resolveApp(app) : null
56
+ // Refused before the folder is bound to it: every later deploy would send its id, and fail the same way.
57
+ if (named?.kind && named.kind !== 'code') {
58
+ throw new UsageError(`${named.slug} is a Chat App, and a folder deploys to a Custom App`, '`frontera app list` names the Apps; pass a Custom App\'s slug to `--app`, or leave `--app` out to deploy to the folder\'s own')
59
+ }
60
+ const ensured = named
61
+ ? { ...named, created: false }
62
+ : await client.ensureApp(project.slug, project.displayName, known.id ?? project.fallbackAppId, { icon: project.icon })
63
+ bindApp(project.root, target, ensured.id)
64
+ const created = ensured.created ? createdApp(ensured.slug, known.id ? 'gone' : known.elsewhere || project.fallbackAppId ? 'elsewhere' : 'first') : null
65
+ if (created) ctx.output.note(` ${created}`)
66
+ // The platform owns the name once the App exists (App settings renames it); the folder's name only creates one.
67
+ else if (ensured.slug !== project.slug) ctx.output.note(` bound to the App ${ensured.slug} here: this folder's own name, ${project.slug}, names an App only on its first deploy to a workspace and never renames one (rename it in Apps, App settings)`)
68
+ return { id: ensured.id, slug: ensured.slug, created, target }
69
+ }
70
+
71
+ /**
72
+ * What a creation says: plain for a folder's first deploy anywhere. A folder
73
+ * that has an App elsewhere, or had one here, gets the way back too, since
74
+ * this workspace may hold its App under another name.
75
+ */
76
+ function createdApp(slug: string, before: 'first' | 'gone' | 'elsewhere'): string {
77
+ if (before === 'first') return `created the App ${slug} in this workspace`
78
+ const why = before === 'gone' ? ': the App this folder deployed to here is gone' : ', this folder\'s first deploy here (it has deployed to other workspaces)'
79
+ return `created a new App ${slug} in this workspace${why}. `
80
+ + 'If this workspace already had the App under another name, deploy to that one instead with `frontera app deploy --app <slug>` (`frontera app list` names them) '
81
+ + `and archive ${slug} in Apps. A deploy never renames an App: rename one in Apps, App settings.`
25
82
  }
@@ -20,7 +20,7 @@ export const appUnpin: Command = {
20
20
  if (!reason?.trim()) throw new UsageError('unpinning needs --reason', 'frontera app unpin --reason "why production may move again"')
21
21
  const project = requireProjectFrom(ctx)
22
22
  const client = new AppsApi(ctx.apiUrl, ctx.token)
23
- const app = await projectApp(client, project)
23
+ const app = await projectApp(ctx, client, project)
24
24
  const res = await client.unpin(app.id, reason)
25
25
  return { data: { app: app.slug, appId: app.id, ...res }, text: `Unpinned ${app.slug}: production keeps serving ${res.productionLabel ?? res.production ?? 'what it serves'}, and promotion works again` }
26
26
  },
@@ -17,7 +17,7 @@ export const appVersions: Command = {
17
17
  async run(ctx) {
18
18
  const project = requireProjectFrom(ctx)
19
19
  const client = new AppsApi(ctx.apiUrl, ctx.token)
20
- const app = await projectApp(client, project)
20
+ const app = await projectApp(ctx, client, project)
21
21
  const versions = await client.listVersions(app.id)
22
22
 
23
23
  return {
@@ -2,6 +2,16 @@ import { readFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
+ import { GovernedActionApi } from '../../api/governed-action-api'
6
+ import {
7
+ blockerLines,
8
+ groupedChanges,
9
+ migrationInstructions,
10
+ newActionSteps,
11
+ changedActionSteps,
12
+ publishPlan,
13
+ } from '../../blueprint/publish-plan'
14
+ import { signInStep } from '../../device-login'
5
15
  import { CliError } from '../../errors'
6
16
  import type { Command, CommandContext } from '../types'
7
17
 
@@ -301,7 +311,7 @@ type ValidationReport = Awaited<ReturnType<BlueprintAuthoringApi['validate']>>
301
311
  * release to the validation that cleared it rather than to whatever the draft
302
312
  * holds later.
303
313
  */
304
- export function renderValidationReport(report: ValidationReport): string {
314
+ export function renderValidationReport(report: Partial<ValidationReport>): string {
305
315
  const id = report?.id ?? report?.reportId
306
316
  const objectTypes = report?.catalogProjection?.objectTypes?.length ?? 0
307
317
  const linkTypes = report?.catalogProjection?.linkTypes?.length ?? 0
@@ -327,7 +337,16 @@ export const blueprintPublish: Command = {
327
337
  verb: 'publish',
328
338
  args: [],
329
339
  flags: { label: 'string', notes: 'string', report: 'string', instruction: 'string' },
340
+ flagHelp: {
341
+ instruction: 'how existing data and environments take the changes that need a migration instruction; sent with each of them (a new Action’s own is written for you)',
342
+ report: 'the draft’s latest validation report to publish against; without it, publish validates the draft first',
343
+ },
330
344
  summary: 'Publish the shared draft as a release',
345
+ details: [
346
+ 'Validates the draft first and posts nothing that would be refused: each blocker is listed with the command that clears it.',
347
+ 'A new governed Action with no deployment decision is recorded as shipping switched off, as `frontera action prepare` does.',
348
+ 'A change that needs a migration instruction (other than a new Action\'s) is listed by kind and name; --instruction explains every one of them.',
349
+ ],
331
350
  examples: [
332
351
  'frontera blueprint publish --label v3 --notes "Adds Customer"',
333
352
  'frontera blueprint publish --label v4 --instruction "Backfilled"',
@@ -337,39 +356,106 @@ export const blueprintPublish: Command = {
337
356
  const client = api(ctx)
338
357
  const label = (ctx.flags.label as string) ?? 'cli-release'
339
358
  const notes = (ctx.flags.notes as string) ?? 'Published with the Frontera CLI.'
340
-
341
- // Validate first when no report was given: publishing against a stale report
342
- // would release something nobody checked.
343
- let validationReportId = ctx.flags.report as string | undefined
344
- if (!validationReportId) {
345
- const report = await client.validate()
346
- validationReportId = report?.id ?? report?.reportId
359
+ const instruction = (ctx.flags.instruction as string | undefined)?.trim() || undefined
360
+ const profile = typeof ctx.flags.profile === 'string' ? ctx.flags.profile : undefined
361
+
362
+ // `--report` is the validation the person read; without it, the draft is validated now.
363
+ let report: ValidationReport
364
+ const reportId = ctx.flags.report as string | undefined
365
+ if (reportId) {
366
+ const latest = (await client.lifecycle() as { latestValidation?: ValidationReport | null }).latestValidation
367
+ if (latest?.id !== reportId) {
368
+ throw new CliError(`Report ${reportId} is not the draft's latest validation, so a release from it would not be what was checked.`, {
369
+ code: 'VALIDATION_STALE',
370
+ hint: 'drop --report (publish validates the draft itself), or run `frontera blueprint validate` and pass its report',
371
+ })
372
+ }
373
+ report = latest
374
+ } else {
375
+ report = await client.validate()
376
+ }
377
+ const signIn = await signInStep({ profile: ctx.profile, apiUrl: ctx.apiUrl })
378
+ let plan = publishPlan(report, signIn, profile)
379
+
380
+ // What `action prepare` records, for each Action that has no decision yet.
381
+ const prepared: string[] = []
382
+ if (plan.autoFixable.length) {
383
+ const actions = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
384
+ for (const name of plan.autoFixable) {
385
+ await actions.recordDeploymentDecision(name, `Published with the Frontera CLI ("${label}"): ships switched off until a person deploys it and turns it on.`)
386
+ prepared.push(name)
387
+ }
388
+ plan = publishPlan(await client.validate(), signIn, profile)
389
+ }
390
+ const preparedLines = prepared.map((name) => `Recorded that Action ${name} ships switched off: a person deploys it and turns it on after publish.`)
391
+
392
+ if (plan.blockers.length) {
393
+ throw new CliError([
394
+ ...preparedLines,
395
+ `The draft can't be published yet, so nothing was published. ${plan.blockers.length} blocker${plan.blockers.length === 1 ? '' : 's'}:`,
396
+ ...blockerLines(plan.blockers),
397
+ ].join('\n'), {
398
+ code: 'RELEASE_BLOCKED',
399
+ hint: 'clear each blocker above, then publish again',
400
+ details: { prepared, plan },
401
+ })
347
402
  }
348
- if (!validationReportId) {
349
- throw new CliError('Validation produced no report id; cannot publish.', {
350
- code: 'FAILURE',
351
- hint: 'frontera blueprint validate',
403
+
404
+ if (plan.instructionsNeeded.length && !instruction) {
405
+ const quote = (value: string) => (/^[\w.:-]+$/.test(value) ? value : JSON.stringify(value))
406
+ const rerun = ['frontera blueprint publish', `--label ${quote(label)}`, ...(ctx.flags.notes ? [`--notes ${quote(notes)}`] : []), '--instruction "<how existing data and environments take these changes>"', ...(profile ? [`--profile ${profile}`] : [])].join(' ')
407
+ const count = plan.instructionsNeeded.length
408
+ throw new CliError([
409
+ ...preparedLines,
410
+ `${count} change${count === 1 ? '' : 's'} in this release need${count === 1 ? 's' : ''} a migration instruction, so nothing was published:`,
411
+ ...groupedChanges(plan.instructionsNeeded).map((line) => ` ${line}`),
412
+ ].join('\n'), {
413
+ code: 'MIGRATION_INSTRUCTION_REQUIRED',
414
+ hint: `say how existing data and environments take these changes; the instruction is kept with each change in the release:\n ${rerun}`,
415
+ details: { prepared, plan },
352
416
  })
353
417
  }
354
418
 
419
+ // Refused by the service, and kept as a failed attempt, if posted.
420
+ if (plan.unchanged) {
421
+ return { data: { release: null, prepared, newActions: [] }, text: [...preparedLines, 'The draft has no unpublished changes, so nothing was published.'].join('\n') }
422
+ }
423
+
355
424
  const released = await client.publish({
356
- expectedRevision: await client.revision(),
357
- validationReportId,
425
+ expectedRevision: plan.draftRevision,
426
+ validationReportId: plan.reportId,
358
427
  releaseLabel: label,
359
428
  releaseNotes: notes,
360
- // A release that discards changes needs an instruction per discarded change.
361
- instruction: ctx.flags.instruction as string | undefined,
429
+ migrationInstructions: migrationInstructions(plan, instruction),
362
430
  })
363
431
  const lifecycle = released as { activeRelease?: { releaseLabel?: string }; alreadyPublished?: boolean } | null
364
- if (!lifecycle?.alreadyPublished) {
365
- return { data: released, text: `Published release "${label}".` }
432
+ if (lifecycle?.alreadyPublished) {
433
+ // A retry of a publish that already went through is answered with the release
434
+ // that is live, which carries the label it was published with, not this run's.
435
+ // One made after newer Draft edits is refused by the service as stale.
436
+ const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
437
+ return { data: { release: released, prepared, newActions: [] }, text: `Release "${activeReleaseLabel}" is already active; nothing was published.` }
438
+ }
439
+ const newActions = plan.newActions.map((action) => ({ ...action, nextSteps: newActionSteps(action, signIn, profile) }))
440
+ const changedActions = plan.changedSwitchedOff.map((action) => ({ apiName: action.apiName, nextSteps: changedActionSteps(action, signIn, profile) }))
441
+ const numbered = (steps: Array<{ step: string; command?: string }>) => steps.flatMap((step, index) => (step.command ? [` ${index + 1}. ${step.step}:`, ` ${step.command}`] : [` ${index + 1}. ${step.step}.`]))
442
+ return {
443
+ data: { release: released, prepared, newActions, changedActions },
444
+ text: [
445
+ ...preparedLines,
446
+ `Published release "${label}".`,
447
+ ...newActions.flatMap((action) => [
448
+ '',
449
+ `New Action ${action.apiName} is published switched off, with no write path. To turn it on:`,
450
+ ...numbered(action.nextSteps),
451
+ ]),
452
+ ...changedActions.flatMap((action) => [
453
+ '',
454
+ `Action ${action.apiName} changed and is published switched off; its old write path does not run the new version. To turn it on again:`,
455
+ ...numbered(action.nextSteps),
456
+ ]),
457
+ ].join('\n'),
366
458
  }
367
- // A retry of a publish that already went through is answered with the release
368
- // that is live, which carries the label it was published with, not this run's.
369
- // One made after newer Draft edits is refused by the service as stale.
370
- const activeReleaseLabel = lifecycle.activeRelease?.releaseLabel ?? label
371
- const nothingPublished = `Release "${activeReleaseLabel}" is already active; nothing was published.`
372
- return { data: released, text: nothingPublished }
373
459
  },
374
460
  }
375
461
 
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
- import { pickCurrentRevision } from '../../blueprint/dataset-revision'
5
+ import { columnMappingsFrom, datasetBindingCommand, pickCurrentRevision, type MappingEntry } from '../../blueprint/dataset-revision'
6
6
  import { readTree, writeTree } from '../../blueprint/tree'
7
7
  import { CliError } from '../../errors'
8
8
  import type { Command, CommandContext } from '../types'
@@ -31,7 +31,7 @@ function api(ctx: CommandContext): BlueprintAuthoringApi {
31
31
 
32
32
  interface MappingFile {
33
33
  dataset: string
34
- mapping: Record<string, string | { column: string; field?: string | null } | null>
34
+ mapping: Record<string, MappingEntry | null>
35
35
  }
36
36
 
37
37
  async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
@@ -58,7 +58,7 @@ async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
58
58
  hint: 'Publish a revision of the dataset, then bind.',
59
59
  })
60
60
  }
61
- return current
61
+ return { ...current, id: current.id }
62
62
  }
63
63
 
64
64
  /**
@@ -150,36 +150,7 @@ export const blueprintBind: Command = {
150
150
  })
151
151
  }
152
152
  const mapping = JSON.parse(readFileSync(resolve(ctx.cwd, filePath), 'utf8')) as MappingFile
153
- const idByApiName = new Map(properties.map((property) => [property.apiName, property.id]))
154
-
155
- const columnMappings: Array<{ propertyId: string; column: string; field?: string | null }> = []
156
- const missing: string[] = []
157
- for (const property of properties) {
158
- const name = property.apiName
159
- // A calculated field (a location from latitude and longitude) reads no column.
160
- if (!name || (property as { calculation?: unknown }).calculation) continue
161
- const entry = mapping.mapping?.[name]
162
- if (entry === undefined || entry === null) {
163
- missing.push(name)
164
- continue
165
- }
166
- const resolved = typeof entry === 'string' ? { column: entry } : entry
167
- const propertyId = idByApiName.get(name)
168
- if (!propertyId) {
169
- // `!` sent `propertyId: undefined` and let the service answer for it. The
170
- // property came from the same read as the mapping, so this means the draft
171
- // moved mid-command.
172
- throw new CliError(`Property "${name}" is no longer on "${apiName}".`, {
173
- code: 'CONFLICT',
174
- hint: 'Re-run `frontera blueprint bind … --plan` against the current draft.',
175
- })
176
- }
177
- columnMappings.push({
178
- propertyId,
179
- column: resolved.column,
180
- ...(resolved.field === undefined || resolved.field === null ? {} : { field: resolved.field }),
181
- })
182
- }
153
+ const { columnMappings, unmapped: missing } = columnMappingsFrom(properties, mapping.mapping ?? {})
183
154
  if (missing.length > 0) {
184
155
  // Refused here rather than at the service: the same refusal, one round trip
185
156
  // earlier, and it can name every unmapped property at once instead of the
@@ -193,20 +164,13 @@ export const blueprintBind: Command = {
193
164
  )
194
165
  }
195
166
 
196
- const result = await client.draftCommand({
197
- kind: 'update_source_binding',
198
- objectId: detail.id,
199
- binding: {
200
- kind: 'dataset',
201
- datasetRevisionId: revision.id,
202
- columnMappings,
203
- ...(ctx.flags['accept-contract-change'] === true ? { acceptContractChange: true } : {}),
204
- },
205
- }, await client.revision())
167
+ const result = await client.draftCommand(
168
+ datasetBindingCommand(detail.id, revision.id, columnMappings, ctx.flags['accept-contract-change'] === true),
169
+ await client.revision(),
170
+ )
206
171
 
207
- // The tree is told what the draft now says. `backing` is excluded from the diff
208
- // — a rebind is this command's act, not `apply`'s — so nothing else would ever
209
- // reconcile the file, and it would sit stale until the next `pull`.
172
+ // The tree is told what the draft now says, or the next `apply` would read the
173
+ // file's old Dataset as a rebind back to it.
210
174
  const treeRoot = resolve(ctx.cwd, (ctx.flags.dir as string) ?? 'blueprint')
211
175
  let treeUpdated = false
212
176
  if (existsSync(join(treeRoot, 'object-types'))) {
@@ -23,15 +23,19 @@ import {
23
23
  } from '../../blueprint/compile'
24
24
  import { metricRuleProblems } from '../../blueprint/metric-rules'
25
25
  import { actionRuleProblems, type ActionRuleProblems, type InheritedConflict } from '../../blueprint/action-rules'
26
- import { pickCurrentRevision } from '../../blueprint/dataset-revision'
26
+ import { datasetBindingCommand, pickCurrentRevision } from '../../blueprint/dataset-revision'
27
27
  import { nameCheckWords, orgNameCheck } from '../../blueprint/org-name-check'
28
28
  import {
29
29
  assertPrunesLeaveNoDanglingReference,
30
30
  diff,
31
+ driftOf,
32
+ editableChanges,
31
33
  isEmpty,
32
34
  satisfies,
35
+ type DatasetIndex,
33
36
  type ModelOperation,
34
37
  type Plan,
38
+ type RebindChange,
35
39
  } from '../../blueprint/diff'
36
40
  import {
37
41
  API_NAME_RULE,
@@ -135,6 +139,66 @@ async function datasetNamesByRevision(
135
139
  return names
136
140
  }
137
141
 
142
+ /**
143
+ * What `plan` needs from the Datasets: the name each pinned revision belongs to, and the
144
+ * current revision of a Dataset a file names. The Dataset list names every current
145
+ * revision, so revisions are read only for a pin that is not current (it may have
146
+ * drifted) and for a Dataset a file names that its type does not read now (it may move).
147
+ * When every type reads its Dataset's current revision, nothing beyond the list is read.
148
+ * Undefined when this credential cannot read Datasets, so nothing is planned from a guess.
149
+ */
150
+ async function readDatasetIndex(
151
+ client: BlueprintAuthoringApi,
152
+ files: AuthoredFile[],
153
+ live: DefinitionBundle,
154
+ ): Promise<DatasetIndex | undefined> {
155
+ const pinOf = (apiName: string) => (live.objects.find((object) => object.apiName === apiName)
156
+ ?.governance.sourceMappings[0] as { datasetRevisionId?: string } | undefined)?.datasetRevisionId
157
+ const named = files.flatMap((file) => {
158
+ const dataset = file.kind === 'object-type' ? (file.document.backing as { dataset?: unknown } | undefined)?.dataset : undefined
159
+ return typeof dataset === 'string' && dataset && live.objects.some((object) => object.apiName === file.apiName)
160
+ ? [{ dataset, pin: pinOf(file.apiName) }]
161
+ : []
162
+ })
163
+ const pins = new Set([...named.flatMap((entry) => entry.pin ?? []), ...driftOf(live).map((pin) => pin.datasetRevisionId)])
164
+ if (named.length === 0 && pins.size === 0) return undefined
165
+ const readable = await readableDatasets(client)
166
+ if (!readable) return undefined
167
+
168
+ const byName = new Map(readable.flatMap((dataset) => (dataset.id && dataset.name ? [[dataset.name, { ...dataset, id: dataset.id }] as const] : [])))
169
+ const index: DatasetIndex = { nameByRevisionId: new Map(), current: new Map() }
170
+ for (const [name, dataset] of byName) {
171
+ if (dataset.currentRevisionId) index.nameByRevisionId.set(dataset.currentRevisionId, name)
172
+ }
173
+ const read = new Set<string>()
174
+ const readRevisions = async (name: string, dataset: { id: string; currentRevisionId?: string }) => {
175
+ if (read.has(name)) return
176
+ read.add(name)
177
+ const revisions = await client.datasetRevisions(dataset.id)
178
+ for (const revision of revisions) {
179
+ if (revision.id) index.nameByRevisionId.set(revision.id, name)
180
+ }
181
+ const current = pickCurrentRevision(dataset, revisions)
182
+ if (!current?.id) return
183
+ index.current.set(name, {
184
+ id: current.id,
185
+ ...(current.schemaDigest ? { schemaDigest: current.schemaDigest } : {}),
186
+ ...(current.columns ? { columns: current.columns.flatMap((column) => (column.name ? [column.name] : [])) } : {}),
187
+ })
188
+ }
189
+ for (const { dataset, pin } of named) {
190
+ const target = byName.get(dataset)
191
+ if (target && (!pin || target.currentRevisionId !== pin)) await readRevisions(dataset, target)
192
+ }
193
+ for (const pin of pins) {
194
+ for (const [name, dataset] of byName) {
195
+ if (index.nameByRevisionId.has(pin)) break
196
+ await readRevisions(name, dataset)
197
+ }
198
+ }
199
+ return index
200
+ }
201
+
138
202
  async function resolveRevision(
139
203
  client: BlueprintAuthoringApi,
140
204
  name: string,
@@ -308,34 +372,18 @@ async function computePlan(ctx: CommandContext): Promise<{
308
372
  })
309
373
  }
310
374
  const { definition, revision } = await client.draft()
311
- const plan = withheldPrunes(diff(files, definition, revision), directory)
375
+ const datasets = await readDatasetIndex(client, files, definition)
376
+ const plan = withheldPrunes(diff(files, definition, revision, datasets), directory)
312
377
 
313
- // Per DISTINCT dataset, which is what the design promised and what the previous
314
- // loop did not do: it called `datasetRevisions` once per BOUND TYPE, after
315
- // `datasetNamesByRevision` had already read every dataset's revisions. A hundred
316
- // types on one dataset cost a hundred redundant round trips.
378
+ // A type moving to another Dataset is no longer held to the old one's contract.
379
+ const rebound = new Set(plan.rebind.map((change) => change.objectApiName))
317
380
  const drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }> = []
318
- if (plan.drift.length > 0) {
319
- // One pass over the datasets, not two: the revisions are read once and both the
320
- // revision→name map and the current-revision map are built from the same reads.
321
- const names = new Map<string, string>()
322
- const currentByDataset = new Map<string, { id: string; schemaDigest?: string }>()
323
- for (const dataset of await readableDatasets(client) ?? []) {
324
- if (!dataset.id || !dataset.name) continue
325
- const revisions = await client.datasetRevisions(dataset.id)
326
- for (const revision of revisions) {
327
- if (revision.id) names.set(revision.id, dataset.name)
328
- }
329
- const current = pickCurrentRevision(dataset, revisions)
330
- if (current?.id) currentByDataset.set(dataset.name, { id: current.id, schemaDigest: current.schemaDigest })
331
- }
332
- for (const pin of plan.drift) {
333
- const datasetName = names.get(pin.datasetRevisionId)
334
- if (!datasetName) continue
335
- const current = currentByDataset.get(datasetName)
336
- if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
337
- drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
338
- }
381
+ for (const pin of plan.drift) {
382
+ const datasetName = datasets?.nameByRevisionId.get(pin.datasetRevisionId)
383
+ if (!datasetName || rebound.has(pin.objectApiName)) continue
384
+ const current = datasets?.current.get(datasetName)
385
+ if (!current?.schemaDigest || current.schemaDigest === pin.pinnedDigest) continue
386
+ drift.push({ objectApiName: pin.objectApiName, datasetName, currentDigest: current.schemaDigest })
339
387
  }
340
388
  const inherits = inheritance(ctx)
341
389
  const actionProblems = actionRuleProblems(files, inherits.conflict)
@@ -1341,8 +1389,8 @@ export const blueprintApply: Command = {
1341
1389
 
1342
1390
  // Drift is REPORTED, and blocks only the artifacts it is about. It used to throw
1343
1391
  // before the plan was even shown, so a colleague's type reading a moved dataset
1344
- // refused an unrelated creation and hid what the run would have done. A rebind is
1345
- // `bind`'s act either way — the plan says so on the line.
1392
+ // refused an unrelated creation and hid what the run would have done. Re-binding to
1393
+ // the same Dataset's new revision is `bind`'s act; the plan says so on the line.
1346
1394
  const driftingNames = new Set(drift.map((entry) => entry.objectApiName))
1347
1395
  const blocked = plan.operations.filter((operation) =>
1348
1396
  operation.kind === 'object-type' && driftingNames.has(operation.apiName))
@@ -1354,6 +1402,16 @@ export const blueprintApply: Command = {
1354
1402
  )
1355
1403
  }
1356
1404
 
1405
+ // A rebind that cannot be sent stops the run before any write, with what to run instead.
1406
+ const refused = plan.rebind.filter((change): change is Extract<RebindChange, { refused: string }> => 'refused' in change)
1407
+ if (refused.length > 0) {
1408
+ throw new CliError(
1409
+ refused.map((change) => `${change.objectApiName} cannot move to Dataset "${change.to}": ${change.refused}.`).join('\n')
1410
+ + '\nNothing was applied.',
1411
+ { code: 'CONFLICT', hint: refused.map((change) => change.next).join('\n') },
1412
+ )
1413
+ }
1414
+
1357
1415
  // BOTH orderings resolved before the confirmation, not inside the write loop.
1358
1416
  // Each refuses a mutual `compensationAction` pair, and a refusal that arrives
1359
1417
  // after the creates have committed leaves the draft half-applied — which is what
@@ -1367,9 +1425,10 @@ export const blueprintApply: Command = {
1367
1425
  const ordered = orderForApply(plan.operations)
1368
1426
  const orderedPrunes = prune ? orderForPrune(plan.prunes, liveCompensations(definition)) : []
1369
1427
 
1428
+ const changeCount = plan.operations.length + plan.editable.length + plan.rebind.length
1370
1429
  if (ctx.flags.yes !== true) {
1371
1430
  throw new CliError(
1372
- `This would apply ${plan.operations.length} change${plan.operations.length === 1 ? '' : 's'}.\n`
1431
+ `This would apply ${changeCount} change${changeCount === 1 ? '' : 's'}.\n`
1373
1432
  + `${renderPlan(plan, { prune })}\n\nRe-run with --yes to apply.`,
1374
1433
  { code: 'USAGE', hint: 'frontera blueprint apply --yes' },
1375
1434
  )
@@ -1398,6 +1457,23 @@ export const blueprintApply: Command = {
1398
1457
  }),
1399
1458
  ),
1400
1459
  }
1460
+ // First, so a property this run adds can read a column only the new Dataset publishes.
1461
+ // The file naming the Dataset is the decision `--accept-contract-change` stands for in `bind`.
1462
+ for (const change of plan.rebind) {
1463
+ if ('refused' in change) continue
1464
+ current = `update object-type ${change.objectApiName} dataset: ${change.from ?? '(none)'} → ${change.to}`
1465
+ try {
1466
+ revision = (await client.draftCommand(datasetBindingCommand(change.objectId, change.datasetRevisionId, change.columnMappings, true), revision)).revision
1467
+ } catch (err) {
1468
+ if (!(err instanceof CliError)) throw err
1469
+ throw new CliError(`backing.dataset (${change.to}) was refused: ${err.message}`, {
1470
+ code: err.code,
1471
+ hint: `frontera blueprint bind ${change.objectApiName} --dataset ${change.to} --plan ./map.json`,
1472
+ cause: err,
1473
+ })
1474
+ }
1475
+ applied.push(current)
1476
+ }
1401
1477
  for (const operation of ordered) {
1402
1478
  const file = byKey.get(`${operation.kind}:${operation.apiName}`)!
1403
1479
  current = `${operation.operation} ${operation.kind} ${operation.apiName}`
@@ -1411,6 +1487,24 @@ export const blueprintApply: Command = {
1411
1487
  applied.push(current)
1412
1488
  }
1413
1489
  }
1490
+ // Last, against the draft as these writes left it: a type created above has its
1491
+ // id, and a property added above can be named.
1492
+ if (plan.editable.length) {
1493
+ const after = await client.draft()
1494
+ revision = after.revision
1495
+ for (const change of editableChanges(files, after.definition)) {
1496
+ current = `update object-type ${change.objectApiName}`
1497
+ const objectId = after.definition.objects.find((object) => object.apiName === change.objectApiName)?.id
1498
+ if (!objectId) throw new CliError(`No object type "${change.objectApiName}" on the draft to declare editable fields on.`, { code: 'NOT_FOUND', hint: 'frontera blueprint plan — re-plan against the current draft, then re-apply.' })
1499
+ try {
1500
+ revision = (await client.draftCommand({ kind: 'set_editable_properties', objectId, editableProperties: change.to }, revision)).revision
1501
+ } catch (err) {
1502
+ if (!(err instanceof CliError)) throw err
1503
+ throw new CliError(`backing.editable (${change.to.join(', ') || 'none'}) was refused: ${err.message}`, { code: err.code, ...(err.hint ? { hint: err.hint } : {}), cause: err })
1504
+ }
1505
+ applied.push(`${current} editable: ${change.to.join(', ') || '(none)'}`)
1506
+ }
1507
+ }
1414
1508
  current = null
1415
1509
  } catch (err) {
1416
1510
  // What landed is as important as what failed: the draft is shared, and a
@@ -5,11 +5,9 @@ import type { Command, CommandContext } from '../types'
5
5
  /**
6
6
  * Declare which properties a governed Action may write.
7
7
  *
8
- * The `set_editable_properties` draft command has existed since the edit
9
- * overlay landed, and until now nothing called it: `apply` excludes backings
10
- * from its diff (`bind` owns them) and `bind` sends only the column mapping.
11
- * So the one declaration standing between a published ontology and an Action
12
- * that can change it was reachable by raw HTTP alone.
8
+ * The `set_editable_properties` draft command, for one object type at a time.
9
+ * `apply` sends the same command for every file whose `backing.editable`
10
+ * differs from the draft; this verb reads or changes the set without a file.
13
11
  *
14
12
  * It is its own verb rather than a flag on `bind` because narrowing the set is
15
13
  * a governed act with its own consequence — an edit already stored against a
@@ -26,6 +26,19 @@ interface KnowledgeRow {
26
26
  }
27
27
 
28
28
 
29
+ /**
30
+ * These commands manage the knowledge bases Agents attach. The Knowledge app's
31
+ * bases (organization and workspace) are another thing with no command or
32
+ * project file, and no local runtime runs the app. Said only where the
33
+ * platform runs the app: elsewhere there is no such base to mistake for one.
34
+ */
35
+ async function knowledgeAppNote(api: PlatformApi): Promise<string> {
36
+ return (await api.knowledgeAppRuns())
37
+ ? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, not on a local runtime. '
38
+ + 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there.'
39
+ : ''
40
+ }
41
+
29
42
  /** Accept a base by name or id — `knowledge list` shows both. */
30
43
  async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
31
44
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -38,7 +51,7 @@ async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<st
38
51
 
39
52
  throw new CliError(`no knowledge base named "${ref}"`, {
40
53
  code: 'NOT_FOUND',
41
- hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}`,
54
+ hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}.${await knowledgeAppNote(client)}`,
42
55
  })
43
56
  }
44
57
 
@@ -61,7 +74,7 @@ const list: Command = {
61
74
  data: rows,
62
75
  text:
63
76
  rows.length === 0
64
- ? 'No knowledge bases in this workspace.'
77
+ ? `No knowledge bases in this workspace.${await knowledgeAppNote(api)}`
65
78
  : // NAME first: it is the value `knowledge sources` takes, not the id.
66
79
  // No `displayName` on these rows, so a column for it was always
67
80
  // blank and shifted the description out of alignment.