@frontera-sdk/cli 1.53.25 → 1.53.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.25",
3
+ "version": "1.53.27",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.25",
44
- "@frontera-sdk/functions": "1.53.25",
43
+ "@frontera-sdk/core": "1.53.27",
44
+ "@frontera-sdk/functions": "1.53.27",
45
45
  "ai": "^6.0.319",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.25",
52
+ "@frontera-sdk/forge-contracts": "1.53.27",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -449,6 +449,180 @@ const testSource: Command = {
449
449
  },
450
450
  }
451
451
 
452
+ type DatasetDefinition = {
453
+ common: { apiName: string; displayName: string; description?: string; keyColumns: string[] | null; deterministicKeyConfirmed: boolean }
454
+ } & (
455
+ | { kind: 'blank'; columns: DatasetColumn[] }
456
+ | { kind: 'source'; source: string; schema: string; relation: string; includedColumns: string[] }
457
+ | { kind: 'file'; csv: string; csvPath: string; includedColumns: string[]; declared: Map<string, { nullable?: boolean; databaseType?: string }> | null }
458
+ )
459
+
460
+ /**
461
+ * A dataset definition as `dataset create` reads it, refused for everything
462
+ * that needs no service to tell. `local dev` checks a dataset the runtime
463
+ * holds with it too, since it loads that one's CSV without `create`.
464
+ */
465
+ export function readDatasetDefinition(path: string, filePath: string): DatasetDefinition {
466
+ let document: DatasetFile
467
+ try {
468
+ document = JSON.parse(readFileSync(path, 'utf8')) as DatasetFile
469
+ } catch (e) {
470
+ // The likeliest way to get here is handing over the CSV itself — the
471
+ // summary says "from … a CSV" and this is the only flag. That used to be
472
+ // a raw parser crash: `INTERNAL_ERROR: JSON Parse error: Unexpected
473
+ // identifier "id"`, exit 1, naming the first CSV header as the fault.
474
+ throw new CliError(`${filePath} is not a JSON definition: ${(e as Error).message}`, {
475
+ code: 'USAGE',
476
+ hint: filePath.toLowerCase().endsWith('.csv')
477
+ ? '--file takes the definition document, not the CSV. To ingest a CSV, the document '
478
+ + 'names it: { "apiName": "…", "kind": "file", "path": "./' + basename(filePath) + '" }'
479
+ : 'the document is JSON: { apiName, kind: blank | source | file, … } — see `frontera dataset create --help`',
480
+ })
481
+ }
482
+
483
+ const apiName = document.apiName
484
+ if (typeof apiName !== 'string' || !apiName) {
485
+ throw new CliError(`${filePath} names no \`apiName\`.`, {
486
+ code: 'USAGE',
487
+ hint: 'apiName is how `backing.dataset` and `blueprint bind` refer to it.',
488
+ })
489
+ }
490
+ const displayName = typeof document.displayName === 'string' && document.displayName
491
+ ? document.displayName
492
+ : apiName
493
+ const description = typeof document.description === 'string'
494
+ ? { description: document.description }
495
+ : {}
496
+
497
+ const keyColumns = document.keyColumns === undefined || document.keyColumns === null
498
+ ? null
499
+ : document.keyColumns
500
+ if (keyColumns !== null && !Array.isArray(keyColumns)) {
501
+ throw new CliError(`${filePath} has \`keyColumns\` that is neither a list nor null.`, {
502
+ code: 'USAGE',
503
+ hint: 'keyColumns: ["id"] for a keyed dataset, or null when no column identifies a row.',
504
+ })
505
+ }
506
+ // Only ever true when the file DECLARED key columns. The flag attests that the key
507
+ // identifies a row uniquely, and attesting that of a key nobody named is a claim
508
+ // the file never made.
509
+ const deterministicKeyConfirmed = document.deterministicKeyConfirmed === true && keyColumns !== null
510
+ const common = {
511
+ apiName,
512
+ displayName,
513
+ ...description,
514
+ keyColumns: keyColumns as string[] | null,
515
+ deterministicKeyConfirmed,
516
+ }
517
+
518
+ /**
519
+ * `kind` defaults to blank, and blank reads NOTHING — so a document that
520
+ * carried `source` or `path` without saying `kind` was created as an empty
521
+ * schema, its row-bearing keys silently dropped. "Created" was the whole
522
+ * answer, and the caller found the zero rows later, somewhere else.
523
+ *
524
+ * The default stays, because a plain columns document is the common case
525
+ * and has always worked. What goes is the silence: keys that only mean
526
+ * something under another kind refuse rather than vanish.
527
+ */
528
+ const doc = document as Record<string, unknown>
529
+ const kind = document.kind ?? 'blank'
530
+
531
+ const KIND_KEYS: Record<string, Set<string>> = {
532
+ blank: new Set(['columns']),
533
+ source: new Set(['source', 'schema', 'relation', 'includedColumns']),
534
+ // `columns`: what the file declares of each column (its nullability, its type) instead of
535
+ // leaving both to the first CSV, whose contract every later upload must then match.
536
+ file: new Set(['path', 'includedColumns', 'columns']),
537
+ }
538
+ const COMMON = new Set([
539
+ 'apiName', 'displayName', 'description', 'kind', 'keyColumns', 'deterministicKeyConfirmed',
540
+ ])
541
+ // Where a stray key probably meant to point. `csv`, `rows` and `data` are
542
+ // what people actually reached for when probing how rows get in.
543
+ const MEANT: Record<string, string> = {
544
+ csv: 'kind: "file" with `path`',
545
+ rows: 'kind: "file" with `path` (rows ride in the CSV)',
546
+ data: 'kind: "file" with `path`',
547
+ source: 'kind: "source"',
548
+ path: 'kind: "file"',
549
+ schema: 'kind: "source"',
550
+ relation: 'kind: "source"',
551
+ }
552
+
553
+ const allowed = KIND_KEYS[kind as string] ?? new Set<string>()
554
+ const strays = Object.keys(doc).filter((k) => !COMMON.has(k) && !allowed.has(k))
555
+ if (strays.length > 0) {
556
+ const guesses = strays
557
+ .map((k) => (MEANT[k] && !allowed.has(k) ? `\`${k}\` belongs to ${MEANT[k]}` : null))
558
+ .filter(Boolean)
559
+ const named = strays.map((k) => '`' + k + '`').join(', ')
560
+ const strayHint = guesses.length > 0
561
+ ? `${guesses.join('; ')} — say the kind explicitly, it does not default from the keys`
562
+ : `kind "${kind}" reads: ${[...COMMON, ...allowed].join(', ')}`
563
+ throw new CliError(`${filePath} carries ${named}, which kind "${kind}" does not read.`, {
564
+ code: 'USAGE',
565
+ hint: strayHint,
566
+ })
567
+ }
568
+
569
+ if (kind === 'blank') {
570
+ const columns = readColumns(document.columns, filePath)
571
+ const declared = new Set(columns.map((column) => column.name))
572
+ for (const key of (keyColumns ?? []) as string[]) {
573
+ if (!declared.has(key)) {
574
+ throw new CliError(`${filePath}: keyColumns names "${key}", which is not a declared column.`, {
575
+ code: 'USAGE',
576
+ hint: `Declared: ${[...declared].join(', ')}`,
577
+ })
578
+ }
579
+ }
580
+ return { common, kind, columns }
581
+ }
582
+ if (kind === 'source') {
583
+ const source = document.source
584
+ if (typeof source !== 'string' || !source) {
585
+ throw new CliError(`${filePath} names no \`source\`.`, {
586
+ code: 'USAGE',
587
+ hint: 'frontera dataset sources — the file names one by displayName, never by id.',
588
+ })
589
+ }
590
+ const schema = document.schema
591
+ const relation = document.relation
592
+ if (typeof schema !== 'string' || !schema || typeof relation !== 'string' || !relation) {
593
+ throw new CliError(`${filePath} needs both \`schema\` and \`relation\`.`, {
594
+ code: 'USAGE',
595
+ hint: 'The relation this dataset pulls, e.g. schema: public, relation: customers.',
596
+ })
597
+ }
598
+ return { common, kind, source, schema, relation, includedColumns: names(document.includedColumns, 'includedColumns', filePath) }
599
+ }
600
+ if (kind === 'file') {
601
+ const csv = document.path
602
+ if (typeof csv !== 'string' || !csv) {
603
+ throw new CliError(`${filePath} names no \`path\` to a CSV.`, {
604
+ code: 'USAGE',
605
+ hint: 'path is resolved relative to the definition file, so the pair can move together.',
606
+ })
607
+ }
608
+ const includedColumns = names(document.includedColumns, 'includedColumns', filePath)
609
+ return {
610
+ common,
611
+ kind,
612
+ csv,
613
+ // Relative to the DEFINITION, not the shell's cwd: the two travel together in a
614
+ // repo, and resolving against cwd broke the moment anyone ran it from elsewhere.
615
+ csvPath: resolve(dirname(path), csv),
616
+ includedColumns,
617
+ declared: document.columns === undefined ? null : readFileColumns(document.columns, includedColumns, filePath),
618
+ }
619
+ }
620
+ throw new CliError(`${filePath} has an unknown \`kind\`: ${String(kind)}.`, {
621
+ code: 'USAGE',
622
+ hint: 'One of: blank, source, file.',
623
+ })
624
+ }
625
+
452
626
  const create: Command = {
453
627
  meta: {
454
628
  noun: 'dataset',
@@ -475,134 +649,16 @@ const create: Command = {
475
649
  hint: 'The file names a `kind`: blank, source, or file.',
476
650
  })
477
651
  }
478
- const path = resolve(ctx.cwd, filePath)
479
- let document: DatasetFile
480
- try {
481
- document = JSON.parse(readFileSync(path, 'utf8')) as DatasetFile
482
- } catch (e) {
483
- // The likeliest way to get here is handing over the CSV itself — the
484
- // summary says "from … a CSV" and this is the only flag. That used to be
485
- // a raw parser crash: `INTERNAL_ERROR: JSON Parse error: Unexpected
486
- // identifier "id"`, exit 1, naming the first CSV header as the fault.
487
- throw new CliError(`${filePath} is not a JSON definition: ${(e as Error).message}`, {
488
- code: 'USAGE',
489
- hint: filePath.toLowerCase().endsWith('.csv')
490
- ? '--file takes the definition document, not the CSV. To ingest a CSV, the document '
491
- + 'names it: { "apiName": "…", "kind": "file", "path": "./' + basename(filePath) + '" }'
492
- : 'the document is JSON: { apiName, kind: blank | source | file, … } — see `frontera dataset create --help`',
493
- })
494
- }
495
-
496
- const apiName = document.apiName
497
- if (typeof apiName !== 'string' || !apiName) {
498
- throw new CliError(`${filePath} names no \`apiName\`.`, {
499
- code: 'USAGE',
500
- hint: 'apiName is how `backing.dataset` and `blueprint bind` refer to it.',
501
- })
502
- }
503
- const displayName = typeof document.displayName === 'string' && document.displayName
504
- ? document.displayName
505
- : apiName
506
- const description = typeof document.description === 'string'
507
- ? { description: document.description }
508
- : {}
509
-
510
- const keyColumns = document.keyColumns === undefined || document.keyColumns === null
511
- ? null
512
- : document.keyColumns
513
- if (keyColumns !== null && !Array.isArray(keyColumns)) {
514
- throw new CliError(`${filePath} has \`keyColumns\` that is neither a list nor null.`, {
515
- code: 'USAGE',
516
- hint: 'keyColumns: ["id"] for a keyed dataset, or null when no column identifies a row.',
517
- })
518
- }
519
- // Only ever true when the file DECLARED key columns. The flag attests that the key
520
- // identifies a row uniquely, and attesting that of a key nobody named is a claim
521
- // the file never made.
522
- const deterministicKeyConfirmed = document.deterministicKeyConfirmed === true && keyColumns !== null
523
- const common = {
524
- apiName,
525
- displayName,
526
- ...description,
527
- keyColumns: keyColumns as string[] | null,
528
- deterministicKeyConfirmed,
529
- }
530
-
652
+ const definition = readDatasetDefinition(resolve(ctx.cwd, filePath), filePath)
653
+ const { common } = definition
654
+ const apiName = common.apiName
531
655
  const api = new DatasetApi(ctx.apiUrl, ctx.token)
532
-
533
- /**
534
- * `kind` defaults to blank, and blank reads NOTHING — so a document that
535
- * carried `source` or `path` without saying `kind` was created as an empty
536
- * schema, its row-bearing keys silently dropped. "Created" was the whole
537
- * answer, and the caller found the zero rows later, somewhere else.
538
- *
539
- * The default stays, because a plain columns document is the common case
540
- * and has always worked. What goes is the silence: keys that only mean
541
- * something under another kind refuse rather than vanish.
542
- */
543
- const doc = document as Record<string, unknown>
544
- const kind = document.kind ?? 'blank'
545
-
546
- const KIND_KEYS: Record<string, Set<string>> = {
547
- blank: new Set(['columns']),
548
- source: new Set(['source', 'schema', 'relation', 'includedColumns']),
549
- // `columns`: what the file declares of each column (its nullability, its type) instead of
550
- // leaving both to the first CSV, whose contract every later upload must then match.
551
- file: new Set(['path', 'includedColumns', 'columns']),
552
- }
553
- const COMMON = new Set([
554
- 'apiName', 'displayName', 'description', 'kind', 'keyColumns', 'deterministicKeyConfirmed',
555
- ])
556
- // Where a stray key probably meant to point. `csv`, `rows` and `data` are
557
- // what people actually reached for when probing how rows get in.
558
- const MEANT: Record<string, string> = {
559
- csv: 'kind: "file" with `path`',
560
- rows: 'kind: "file" with `path` (rows ride in the CSV)',
561
- data: 'kind: "file" with `path`',
562
- source: 'kind: "source"',
563
- path: 'kind: "file"',
564
- schema: 'kind: "source"',
565
- relation: 'kind: "source"',
566
- }
567
-
568
- const allowed = KIND_KEYS[kind as string] ?? new Set<string>()
569
- const strays = Object.keys(doc).filter((k) => !COMMON.has(k) && !allowed.has(k))
570
- if (strays.length > 0) {
571
- const guesses = strays
572
- .map((k) => (MEANT[k] && !allowed.has(k) ? `\`${k}\` belongs to ${MEANT[k]}` : null))
573
- .filter(Boolean)
574
- const named = strays.map((k) => '`' + k + '`').join(', ')
575
- const strayHint = guesses.length > 0
576
- ? `${guesses.join('; ')} — say the kind explicitly, it does not default from the keys`
577
- : `kind "${kind}" reads: ${[...COMMON, ...allowed].join(', ')}`
578
- throw new CliError(`${filePath} carries ${named}, which kind "${kind}" does not read.`, {
579
- code: 'USAGE',
580
- hint: strayHint,
581
- })
582
- }
583
-
584
656
  let created
585
657
 
586
- if (kind === 'blank') {
587
- const columns = readColumns(document.columns, filePath)
588
- const declared = new Set(columns.map((column) => column.name))
589
- for (const key of (keyColumns ?? []) as string[]) {
590
- if (!declared.has(key)) {
591
- throw new CliError(`${filePath}: keyColumns names "${key}", which is not a declared column.`, {
592
- code: 'USAGE',
593
- hint: `Declared: ${[...declared].join(', ')}`,
594
- })
595
- }
596
- }
597
- created = await api.createBlank({ ...common, columns })
598
- } else if (kind === 'source') {
599
- const sourceName = document.source
600
- if (typeof sourceName !== 'string' || !sourceName) {
601
- throw new CliError(`${filePath} names no \`source\`.`, {
602
- code: 'USAGE',
603
- hint: 'frontera dataset sources — the file names one by displayName, never by id.',
604
- })
605
- }
658
+ if (definition.kind === 'blank') {
659
+ created = await api.createBlank({ ...common, columns: definition.columns })
660
+ } else if (definition.kind === 'source') {
661
+ const sourceName = definition.source
606
662
  // Named, not id'd: a Source id is minted per deployment, so a definition
607
663
  // carrying one would be correct here and wrong in the next organization —
608
664
  // the same reason nothing in a Blueprint tree carries a uuid.
@@ -616,34 +672,15 @@ const create: Command = {
616
672
  : `Visible: ${all.map((entry) => entry.displayName).filter(Boolean).join(', ')}`,
617
673
  })
618
674
  }
619
- const schema = document.schema
620
- const relation = document.relation
621
- if (typeof schema !== 'string' || !schema || typeof relation !== 'string' || !relation) {
622
- throw new CliError(`${filePath} needs both \`schema\` and \`relation\`.`, {
623
- code: 'USAGE',
624
- hint: 'The relation this dataset pulls, e.g. schema: public, relation: customers.',
625
- })
626
- }
627
675
  created = await api.createFromSource({
628
676
  ...common,
629
677
  sourceId: source.id,
630
- schema,
631
- relation,
632
- includedColumns: names(document.includedColumns, 'includedColumns', filePath),
678
+ schema: definition.schema,
679
+ relation: definition.relation,
680
+ includedColumns: definition.includedColumns,
633
681
  })
634
- } else if (kind === 'file') {
635
- const csv = document.path
636
- if (typeof csv !== 'string' || !csv) {
637
- throw new CliError(`${filePath} names no \`path\` to a CSV.`, {
638
- code: 'USAGE',
639
- hint: 'path is resolved relative to the definition file, so the pair can move together.',
640
- })
641
- }
642
- // Relative to the DEFINITION, not the shell's cwd: the two travel together in a
643
- // repo, and resolving against cwd broke the moment anyone ran it from elsewhere.
644
- const csvPath = resolve(dirname(path), csv)
645
- const included = names(document.includedColumns, 'includedColumns', filePath)
646
- const declared = document.columns === undefined ? null : readFileColumns(document.columns, included, filePath)
682
+ } else {
683
+ const { csv, csvPath, includedColumns: included, declared } = definition
647
684
  if (!declared) {
648
685
  created = await api.createFromFile(csvPath, { ...common, includedColumns: included })
649
686
  } else {
@@ -656,11 +693,6 @@ const create: Command = {
656
693
  }
657
694
  created = await api.uploadSnapshot(blank.id, csvPath)
658
695
  }
659
- } else {
660
- throw new CliError(`${filePath} has an unknown \`kind\`: ${String(kind)}.`, {
661
- code: 'USAGE',
662
- hint: 'One of: blank, source, file.',
663
- })
664
696
  }
665
697
 
666
698
  return {
@@ -668,7 +700,7 @@ const create: Command = {
668
700
  // Blank is a CONTRACT with no rows, and that surprised everyone who
669
701
  // reached it by default. Said here, at creation, rather than left for
670
702
  // `dataset rows` to reveal after the binding is already built.
671
- text: `Created dataset "${apiName}"${kind === 'blank'
703
+ text: `Created dataset "${apiName}"${definition.kind === 'blank'
672
704
  ? ` — declared columns only, no rows. Load them with \`frontera dataset upload ${apiName} ./rows.csv\`,`
673
705
  + ' or create with kind "file" (a CSV) or kind "source" instead.'
674
706
  : '.'} `
@@ -8,7 +8,7 @@ import { CliError, UsageError } from '../../errors'
8
8
  import { ensureProjectBunfig } from '../../local/local-scaffold'
9
9
  import { llmParameterNotes } from '../../local/local-model-parameters'
10
10
  import { findOrphans, orphanCalls, restoreIfArchived, retireDeleted, reviveFunction, runtimeItems } from '../../local/local-orphans'
11
- import { adaptationOf, contentHash, refOfTarget, failureCodeAndHint, isEnvironmentNote, loadBuild, loadHashes, oneLine, promotedVersionOf, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
11
+ import { adaptationOf, contentHash, refOfTarget, failureCodeAndHint, isEnvironmentNote, loadBuild, loadHashes, oneLine, promotedVersionOf, pruneBuild, recordBuild, recordSettingsDifferences, refusedForColumns, saveHashes, type BuildEntry } from '../../local/local-applied'
12
12
  import { INHERITED_FROM_PULL } from '../../blueprint/action-rules'
13
13
  import { actionsNotRunning, deployActions, ensureActionDraft, liveActionStates, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
14
14
  import { agentIdentity, hasAgentDraft, syncAgentName } from '../../local/local-agent-name'
@@ -20,6 +20,7 @@ import {
20
20
  blueprintObjectTypes,
21
21
  byApplyOrder,
22
22
  commandFor,
23
+ datasetDocument,
23
24
  fallbackFor,
24
25
  describeTarget,
25
26
  followUpsFor,
@@ -43,6 +44,7 @@ import { applyOperatingConfig } from '../../local/local-operating'
43
44
  import { localEmbeddingsReachable } from '../../local/local-keys'
44
45
  import { mirrorOrgSettings, settingsCalls } from '../../local/local-org-settings'
45
46
  import { directoryOf, readSnapshot } from '../../local/local-sync-snapshot'
47
+ import { syncClient } from '../../local/local-sync-client'
46
48
  import { BUNDLE_ID_MAP_ENV, bundleIdMap, functionCodeFiles, hardCodedIdLine, hardCodedObjectTypeIds, platformObjectTypeIds } from '../../local/local-function-ids'
47
49
  import { checkRunnable, functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
48
50
  import { functionSecretNames, localSecretPresence, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
@@ -67,6 +69,7 @@ import { canChangeRuntime, filesChangedTrigger, readApplies, recordApply } from
67
69
  import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnswering } from '../../local/local-runtime-health'
68
70
  import { noticeLines, projectNotices } from '../../local/local-project-notices'
69
71
  import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
72
+ import { readDatasetDefinition } from '../dataset/index-commands'
70
73
  import { flagBool, type Command, type CommandContext } from '../types'
71
74
  import { applyEvents } from '../../telemetry/apply-events'
72
75
  import { recordExit } from '../../telemetry/cli-telemetry'
@@ -268,7 +271,16 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
268
271
  if (target.kind === 'dataset') {
269
272
  const media = mediaCatalogDatasetReason(project.root, target.apiName)
270
273
  if (media) return { ok: true, output: ` warning: dataset ${target.apiName} is skipped here: ${media}` }
274
+ // Refused as `dataset create` refuses it, before anything is asked: one the runtime holds is loaded without `create`.
275
+ try {
276
+ readDatasetDefinition(join(project.root, target.file), target.file)
277
+ } catch (err) {
278
+ if (!(err instanceof CliError)) throw err
279
+ return withNotes({ ok: false, output: [`error [${err.code}]: ${err.message}`, ...(err.hint ? [` ${err.hint}`] : [])].join('\n') })
280
+ }
271
281
  }
282
+ // A dataset the runtime holds already takes its rows as a snapshot (`fallbackFor`): `dataset create` would be refused, every apply.
283
+ const held = target.kind === 'dataset' && creds ? await datasetOnRuntime(opts.serviceUrl, creds.orgKey, datasetDocument(project.root, target)?.apiName ?? target.apiName) : false
272
284
  if (resolved.file) target = { ...target, file: resolved.file } as SyncTarget
273
285
  // A Function whose code hard-codes the workspace's object type ids (a pulled one): said, and its
274
286
  // bundle here built with this runtime's ids in their place; the files keep the workspace's.
@@ -328,7 +340,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
328
340
  if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
329
341
  }
330
342
  }
331
- result = withNotes(await runCli(project, command, opts.auth, bundleEnv))
343
+ result = held ? { ok: false, output: '' } : withNotes(await runCli(project, command, opts.auth, bundleEnv))
332
344
  if (blueprint && !result.ok && refused.size < MAX_REFUSED) {
333
345
  const stop = refusedArtifact(result.output)
334
346
  if (stop && !refused.has(stop.key)) {
@@ -345,7 +357,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
345
357
  break
346
358
  }
347
359
  const fallback = fallbackFor(target, profiles, project.root)
348
- if (!result.ok && fallback?.when.test(result.output)) {
360
+ if (fallback && (held || (!result.ok && fallback.when.test(result.output)))) {
349
361
  // An empty fallback means there was nothing more to do.
350
362
  result = { ok: true, output: '' }
351
363
  for (const args of fallback.steps) {
@@ -359,7 +371,8 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
359
371
  // fits its revision, so the definition becomes the dataset's next revision.
360
372
  if (!result.ok && target.kind === 'dataset' && /requires a new revision/i.test(result.output)) {
361
373
  const revised = await runCli(project, ['dataset', 'revise', '-f', target.file, '--profile', profiles.organization], opts.auth)
362
- if (!revised.ok) return { ...revised, output: [result.output.trim(), revised.output.trim()].join('\n') }
374
+ // The revision's refusal names the column and the way out; the upload's "requires a new revision" was answered by trying it.
375
+ if (!revised.ok) return withNotes(revised)
363
376
  // Each object type reading it is bound to the new revision (a binding is pinned to the revision it was made on).
364
377
  const rebound = await rebindReaders(project, target.apiName, profiles, (args) => runCli(project, args, opts.auth))
365
378
  if (!rebound.ok) return rebound
@@ -652,6 +665,22 @@ async function rebindReaders(project: LocalProject, dataset: string, profiles: S
652
665
  return { ok: true, output: lines.join('\n') }
653
666
  }
654
667
 
668
+ /** Whether the runtime holds a dataset of this apiName; null when it could not say (then `dataset create` finds out). */
669
+ export async function datasetOnRuntime(serviceUrl: string, orgKey: string, apiName: string): Promise<boolean | null> {
670
+ const org = syncClient(serviceUrl, orgKey)
671
+ try {
672
+ let cursor: string | null | undefined
673
+ do {
674
+ const page = await org.get<{ items?: Array<{ apiName?: string }>; nextCursor?: string | null }>(`/v1/data-integration/datasets?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`)
675
+ if (page.items?.some((d) => d.apiName === apiName)) return true
676
+ cursor = page.nextCursor
677
+ } while (cursor)
678
+ return false
679
+ } catch {
680
+ return null
681
+ }
682
+ }
683
+
655
684
  /** The local runner's loopback address, to ask it to sync a deploy at once. */
656
685
  function runnerAt(project: LocalProject): { runnerUrl?: string } {
657
686
  const port = readState(project)?.ports.runner
@@ -842,7 +871,8 @@ async function dev(ctx: CommandContext) {
842
871
  }
843
872
 
844
873
  const organizationId = state.credentials!.organizationId
845
- const applied = flagBool(ctx, 'reapply') ? new Map<string, string | null>() : loadHashes(project, organizationId)
874
+ const reapply = flagBool(ctx, 'reapply')
875
+ const applied = reapply ? new Map<string, string | null>() : loadHashes(project, organizationId)
846
876
 
847
877
  // The organization's settings as the last fetch read them: the timezone is taken, every
848
878
  // other difference is said here and in the build summary.
@@ -930,6 +960,13 @@ async function dev(ctx: CommandContext) {
930
960
  if (stopping) return
931
961
  if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
932
962
  const hash = contentHash(project, target)
963
+ const recorded = loadBuild(project, organizationId)[target.key]
964
+ // A dataset refused for its columns is refused again while its files are the same: said, not applied again.
965
+ if (target.kind === 'dataset' && hash && !reapply && recorded?.status === 'failed' && recorded.hash === hash && refusedForColumns(recorded.error)) {
966
+ failing.set(target.key, { item: describeTarget(target), error: recorded.error! })
967
+ ctx.output.note(` ✕ ${describeTarget(target)}: ${recorded.error} Not applied again until its files change (--reapply tries it now).`)
968
+ return
969
+ }
933
970
  // Unchanged files, but the runtime lost the Agent's draft (it was published): the draft is
934
971
  // what its test cases and Studio's Draft chat run, so it is staged again from the files.
935
972
  const draftGone = hash && applied.get(target.key) === hash && target.kind === 'agent' && (await hasAgentDraft({ slug: target.slug, serviceUrl, creds })) === false
@@ -940,7 +977,6 @@ async function dev(ctx: CommandContext) {
940
977
  for (const a of actionsDown) ctx.output.note(` governed Action ${a.apiName} doesn’t run on the runtime (${a.why}): applying the Blueprint again`)
941
978
  // Unchanged, but applied before the build recorded which version the file was pushed as:
942
979
  // pushed once more, so its tests run exactly that version (never a stale draft).
943
- const recorded = loadBuild(project, organizationId)[target.key]
944
980
  const versionUnknown = hash && applied.get(target.key) === hash && target.kind === 'workflow' && (!recorded || (recorded.status !== 'failed' && recorded.status !== 'blocked' && recorded.version === undefined))
945
981
  if (versionUnknown) ctx.output.note(` ${describeTarget(target)}: pushing ${target.file} again to record the version it runs as`)
946
982
  // A knowledge base's threshold follows the runtime's embeddings (`knowledgeSettings`): when they changed since
@@ -1077,7 +1113,9 @@ async function dev(ctx: CommandContext) {
1077
1113
  // A failed apply may have changed the runtime (an Action retired, switched off): the file is not
1078
1114
  // "unchanged" afterwards, even when it is restored to what last applied.
1079
1115
  if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
1080
- failed.set(target.key, target)
1116
+ // A dataset refused for its columns is refused again whatever else applies (`runOne`); any other failure is tried
1117
+ // again after progress, as a dataset's rebind can wait on the Blueprint and a storage error can pass.
1118
+ if (target.kind !== 'dataset' || !refusedForColumns(applyFailureReason(result.output))) failed.set(target.key, target)
1081
1119
  events.item(target, 'failed', round.cause, ms, { output: result.output })
1082
1120
  // A retry that fails the same way is not said again in the same round of changes.
1083
1121
  // A governed Action's checks are each a line to fix: shown whole, not cut to the first seven lines.
package/src/exit.ts CHANGED
@@ -507,3 +507,25 @@ export function sessionLaneFailure(
507
507
  if (err instanceof CliError) return null
508
508
  return new CliError(failure.message, { code: failure.code, hint, cause: err })
509
509
  }
510
+
511
+ /** What a fetch fails with when the platform never answered: refused, no such host, no route to it. Bun's own names too. */
512
+ const NO_ANSWER = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', 'ConnectionRefused', 'FailedToOpenSocket'])
513
+
514
+ /**
515
+ * A request that never reached the platform, as one failure whichever client
516
+ * sent it: `UNAVAILABLE`, "<origin> did not answer: …", the origin the fetch
517
+ * tried (Bun names it) or else the command's. When that origin is the
518
+ * project's own local runtime, the hint is to start it. Null for any other failure.
519
+ */
520
+ export function unreachableFailure(err: unknown, apiUrl: string, isLocalRuntime: (origin: string) => boolean = () => false): CliError | null {
521
+ if (!(err instanceof Error) || !NO_ANSWER.has(String((err as { code?: unknown }).code))) return null
522
+ const tried = (err as { path?: unknown }).path
523
+ let origin = 'the platform'
524
+ try {
525
+ origin = new URL(typeof tried === 'string' && tried ? tried : apiUrl).origin
526
+ } catch {
527
+ // No address to name.
528
+ }
529
+ const hint = isLocalRuntime(origin) ? 'the project’s local runtime is not running: start it with `frontera local start`, then run the command again' : 'check the address and your network, then run the command again'
530
+ return new CliError(`${origin} did not answer: ${err.message}`, { code: 'UNAVAILABLE', hint, cause: err })
531
+ }
@@ -179,6 +179,18 @@ export const IMPORT_MISSING_FIX = 'the pull couldn’t bring the files it import
179
179
  */
180
180
  export const EMBEDDING_MISSING_FIX = 'not its file: the runtime has no embedding model that runs. `frontera local status` says why under Setting and Embeddings; a key the provider refuses is replaced with `frontera local key-set <provider>`, and `frontera local stop` then `frontera local start` sets a model (the keyless local embeddings when no key works); the next `frontera local dev` reads it again'
181
181
 
182
+ /** What fixes a dataset whose file retypes, adds or drops a column: no revision of the runtime's copy takes that. */
183
+ export const DATASET_COLUMNS_FIX = 'make the CSV fit the columns the dataset has here again, give it another apiName (a new dataset), or run `frontera local reset`, which creates every dataset again from its definition'
184
+
185
+ /**
186
+ * A dataset refused for its columns (`dataset revise`: one retyped, added or
187
+ * dropped). Applying the same files again is refused the same way; only they
188
+ * change it. Read from the error's start: a long list of columns is cut.
189
+ */
190
+ export function refusedForColumns(error: string | undefined): boolean {
191
+ return /^The file (?:changes the type of|has no|carries) /.test(error ?? '')
192
+ }
193
+
182
194
  /**
183
195
  * What fixes an item whose apply failed for these very files: the hint the
184
196
  * apply gave, what a known failure needs, else fixing the file. Applying the
@@ -187,6 +199,7 @@ export const EMBEDDING_MISSING_FIX = 'not its file: the runtime has no embedding
187
199
  export function failureFix(entry: Pick<BuildEntry, 'error' | 'code' | 'hint'>, file: string): string {
188
200
  if (entry.code === 'FUNCTION_IMPORT_MISSING' || /^imports .+, which (?:isn[’']t|aren[’']t) in the project/i.test(entry.error ?? '')) return IMPORT_MISSING_FIX
189
201
  if (/set this organization['’]s embedding model|embedding model .*is not available/i.test(entry.error ?? '')) return EMBEDDING_MISSING_FIX
202
+ if (refusedForColumns(entry.error)) return DATASET_COLUMNS_FIX
190
203
  if (entry.hint) return entry.hint
191
204
  return `change ${file} so the runtime takes it; \`frontera local dev\` applies it once the file changes`
192
205
  }
@@ -47,7 +47,10 @@ const GENERIC = /^(invalid workflow definition|blueprint validation is blocked|v
47
47
  * that says why: a blocker, a definition error, the hint.
48
48
  */
49
49
  export function applyFailureReason(output: string): string {
50
- const text = applyErrorText(output)
50
+ const said = applyErrorText(output)
51
+ // `blueprint apply` leads with what landed ("Nothing was applied.", the list's bare `error [CODE]:`) and the step it stopped at; the reason is under them.
52
+ const reasons = said.split('\n').filter((l) => !/^(?:|Nothing was applied\.|Stopped at: .*)$/.test(oneLine(l)))
53
+ const text = reasons.length ? reasons.join('\n') : said
51
54
  const head = oneLine(text)
52
55
  if (!GENERIC.test(head)) return head
53
56
  const detail = text
@@ -55,7 +55,7 @@ export async function runtimeAnswering(project: LocalProject, deps: HealthDeps =
55
55
  export async function failedForRuntime(project: LocalProject, output: string, deps: HealthDeps = {}): Promise<boolean> {
56
56
  if (!(await runtimeAnswering(project, deps))) return true
57
57
  const first = output.split('\n').find((l) => /^error/i.test(l.trim())) ?? ''
58
- return /\[INTERNAL_ERROR\]/.test(first) && isConnectionError(first)
58
+ return /\[(?:INTERNAL_ERROR|UNAVAILABLE)\]/.test(first) && isConnectionError(first)
59
59
  }
60
60
 
61
61
  export interface DatabaseReadyOptions {
@@ -1,3 +1,4 @@
1
+ import { portBlockOf } from './local-ports'
1
2
  import { localProject, type LocalProject } from './local-project'
2
3
  import { isAlive, readState, type LocalCredentials, type LocalState } from './local-state'
3
4
 
@@ -40,3 +41,14 @@ export function localRuntimeAt(cwd: string, apiUrl: string): LocalRuntimeTarget
40
41
  if (!sameService(serviceUrl, apiUrl)) return null
41
42
  return { project, state, creds: state.credentials, serviceUrl }
42
43
  }
44
+
45
+ /** Whether `apiUrl` is the service of the local runtime of the project `cwd` is in, running or not: the port it keeps. */
46
+ export function isLocalRuntimeAddress(cwd: string, apiUrl: string): boolean {
47
+ try {
48
+ const project = localProject(cwd)
49
+ const port = readState(project)?.ports?.service ?? portBlockOf(project.id)?.service
50
+ return port !== undefined && sameService(`http://127.0.0.1:${port}`, apiUrl)
51
+ } catch {
52
+ return false
53
+ }
54
+ }
package/src/main.ts CHANGED
@@ -5,9 +5,9 @@ import { credentialStore } from './credential-store'
5
5
  import { readDeviceSession } from './device-login'
6
6
  import { findProjectRoot } from './context'
7
7
  import { CliError, UsageError } from './errors'
8
- import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './exit'
8
+ import { EXIT, sessionLaneFailure, sessionLaneRefusal, unreachableFailure, type ExitCode } from './exit'
9
9
  import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
10
- import { localRuntimeAt } from './local/local-runtime-target'
10
+ import { isLocalRuntimeAddress, localRuntimeAt } from './local/local-runtime-target'
11
11
  import { acquireLock, lockRefusal, locksRuntime, projectStateDir, recordWrite, writeCommand } from './local/local-writers'
12
12
  import { tokenFromStdin } from './mcp/mcp-apps'
13
13
  import { resolveOrganization, setSelectedOrganization } from './organization'
@@ -16,7 +16,7 @@ import { remoteFromLocalProject, remoteWords } from './remote-guard'
16
16
  import { createOutput, type OutputMode } from './output'
17
17
  import { readProject } from './project'
18
18
  import { commandScope, unaddressedWorkspaceRefusal } from './scopes'
19
- import { cliTelemetry, INTERRUPTED, invocationVia, projectHashAt, severityOfExit, TERMINATED } from './telemetry/cli-telemetry'
19
+ import { cliTelemetry, INTERRUPTED, invocationVia, projectHashAt, projectNamesAt, severityOfExit, TERMINATED } from './telemetry/cli-telemetry'
20
20
  import { isBackground } from './telemetry/enabled'
21
21
  import { errorCode, errorFields } from './telemetry/redact'
22
22
  import {
@@ -96,7 +96,7 @@ function recordCommand(exitCode: number): void {
96
96
  const telemetry = cliTelemetry()
97
97
  if (telemetry.mode.mode === 'off') return
98
98
  const { failure } = invocation
99
- const error = failure === undefined ? undefined : errorFields(errorCode((failure as { code?: unknown })?.code), failure instanceof Error ? failure.message : String(failure), invocation.names)
99
+ const error = failure === undefined ? undefined : errorFields(errorCode((failure as { code?: unknown })?.code), failure instanceof Error ? failure.message : String(failure), [...invocation.names, ...projectNamesAt(invocation.cwd)])
100
100
  const project = projectHashAt(invocation.cwd)
101
101
  telemetry.record({
102
102
  name: 'frontera.cli.command',
@@ -494,7 +494,8 @@ async function main(): Promise<number> {
494
494
  // A key on a session-lane command: one recovery for every such noun,
495
495
  // rather than each API client rewording its own 401/403.
496
496
  const laneFailure = sessionLaneFailure(command.meta, token, err)
497
- const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, typeof flags.dir === 'string' ? flags.dir : process.cwd(), { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? err
497
+ const dir = typeof flags.dir === 'string' ? flags.dir : process.cwd()
498
+ const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, dir, { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? unreachableFailure(err, apiUrlUsed, (origin) => isLocalRuntimeAddress(dir, origin)) ?? err
498
499
  invocation.failure = failure
499
500
  return output.fail(personaToken ? redact(failure, personaToken) : failure)
500
501
  }
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from 'node:readline'
2
2
 
3
- import { cliTelemetry, projectHashAt } from '../telemetry/cli-telemetry'
3
+ import { cliTelemetry, projectHashAt, projectNamesAt } from '../telemetry/cli-telemetry'
4
4
  import { errorCode, errorFields, outputErrorCode } from '../telemetry/redact'
5
5
 
6
6
  /**
@@ -93,7 +93,7 @@ function recordToolCall(tool: string, input: Record<string, unknown>, result: To
93
93
  const telemetry = cliTelemetry()
94
94
  if (telemetry.mode.mode === 'off') return
95
95
  const text = result.content.find((c) => c.type === 'text')?.text ?? ''
96
- const names = Object.values(input).filter((v): v is string => typeof v === 'string')
96
+ const names = [...Object.values(input).filter((v): v is string => typeof v === 'string'), ...projectNamesAt(process.cwd())]
97
97
  const code = thrown === undefined ? outputErrorCode(text) : errorCode((thrown as { code?: unknown })?.code)
98
98
  const project = projectHashAt(process.cwd())
99
99
  telemetry.record({
@@ -5,7 +5,7 @@ import { writeFileAtomic } from '../fs-atomic'
5
5
  import { applyFailureReason } from '../local/local-apply-error'
6
6
  import type { SyncTarget } from '../local/local-dev-sync'
7
7
  import type { LocalProject } from '../local/local-project'
8
- import { cliTelemetry } from './cli-telemetry'
8
+ import { cliTelemetry, projectNamesAt } from './cli-telemetry'
9
9
  import type { ApplyOutcome, ApplyTrigger, Severity } from './events'
10
10
  import type { Recorder } from './recorder'
11
11
  import { errorCode, errorFields, outputErrorCode } from './redact'
@@ -106,11 +106,12 @@ export function applyEvents(project: LocalProject, opts: { recorder?: Recorder;
106
106
  if (!on) return
107
107
  try {
108
108
  const { attempt, recovered } = step(target.key, outcome)
109
+ const names = failure ? [...targetNames(target), ...projectNamesAt(project.root)] : []
109
110
  const error = !failure
110
111
  ? undefined
111
112
  : 'output' in failure
112
- ? errorFields(outputErrorCode(failure.output), applyFailureReason(failure.output), targetNames(target))
113
- : errorFields(errorCode((failure.error as { code?: unknown })?.code), failure.error instanceof Error ? failure.error.message : String(failure.error), targetNames(target))
113
+ ? errorFields(outputErrorCode(failure.output), applyFailureReason(failure.output), names)
114
+ : errorFields(errorCode((failure.error as { code?: unknown })?.code), failure.error instanceof Error ? failure.error.message : String(failure.error), names)
114
115
  telemetry.record({
115
116
  name: 'frontera.cli.apply',
116
117
  severity: SEVERITY[outcome],
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
6
  import pkg from '../../package.json' with { type: 'json' }
7
+ import { KIND_DIRECTORY } from '../blueprint/model'
7
8
  import { findSolutionRoot, localProjectAt, PROJECT_FILES } from '../local/local-project'
8
9
  import { forgeSessionOf } from '../provenance'
9
10
  import { telemetryMode } from './enabled'
@@ -71,6 +72,33 @@ export function projectHashAt(cwd: string, home: string = homedir()): string | u
71
72
  return telemetry.hash(localProjectAt(root).id)
72
73
  }
73
74
 
75
+ /**
76
+ * What the project's Blueprint artifacts and datasets are named, for an error
77
+ * template to take out: a service's reason names them, and a one-word name
78
+ * (`tickets`, `Loan`) passes every redaction rule.
79
+ */
80
+ export function projectNamesAt(cwd: string): string[] {
81
+ const root = findSolutionRoot(cwd)
82
+ const stems = (dir: string) => {
83
+ try {
84
+ return readdirSync(dir).flatMap((f) => /^(.+)\.(?:ya?ml|json)$/.exec(f)?.slice(1) ?? [])
85
+ } catch {
86
+ return []
87
+ }
88
+ }
89
+ const datasets = stems(join(root, 'datasets'))
90
+ // A definition's apiName is the dataset's name even when its file is named otherwise.
91
+ const declared = datasets.flatMap((d) => {
92
+ try {
93
+ const { apiName } = JSON.parse(readFileSync(join(root, 'datasets', `${d}.json`), 'utf8')) as { apiName?: unknown }
94
+ return typeof apiName === 'string' ? [apiName] : []
95
+ } catch {
96
+ return []
97
+ }
98
+ })
99
+ return [...Object.values(KIND_DIRECTORY).flatMap((dir) => stems(join(root, 'blueprint', dir))), ...datasets, ...declared]
100
+ }
101
+
74
102
  /** The exit code of a command the person stopped with Ctrl+C (128 + SIGINT), as a shell reports it. */
75
103
  export const INTERRUPTED = 130
76
104
 
@@ -48,8 +48,10 @@ const ERROR_LINE = /^error \[[A-Z][A-Z0-9_]*\]:\s*/
48
48
  */
49
49
  export function errorTemplate(message: string, names: readonly string[] = []): string {
50
50
  const lines = message.split('\n').map((l) => l.trim()).filter(Boolean)
51
+ // `blueprint apply` leads with what landed and the step it stopped at: its reason is the first line past them.
52
+ const reason = /^(?:Nothing was applied\.|Applied before failing:)$/.test(lines[0] ?? '') ? lines.find((l, i) => i > 0 && !/^(?:Stopped at: |(?:create|update|delete|publish|grant) )/.test(l)) : undefined
51
53
  // Cut first: the rules backtrack quadratically on a long run of word characters, and a template keeps 300 anyway.
52
- let text = (lines.find((l) => ERROR_LINE.test(l)) ?? lines[0] ?? '').replace(ERROR_LINE, '').slice(0, MAX_INPUT)
54
+ let text = (reason ?? lines.find((l) => ERROR_LINE.test(l)) ?? lines[0] ?? '').replace(ERROR_LINE, '').slice(0, MAX_INPUT)
53
55
  const known = [...new Set(names.map((n) => n.trim()).filter((n) => n.length > 1))].sort((a, b) => b.length - a.length)
54
56
  if (known.length) text = text.replace(new RegExp(`(?<![\\w-])(?:${known.map(escape).join('|')})(?![\\w-])`, 'gi'), X)
55
57
  for (const rule of RULES) text = text.replace(rule, X)