@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 +4 -4
- package/src/commands/dataset/index-commands.ts +187 -155
- package/src/commands/local/dev.ts +45 -7
- package/src/exit.ts +22 -0
- package/src/local/local-applied.ts +13 -0
- package/src/local/local-apply-error.ts +4 -1
- package/src/local/local-runtime-health.ts +1 -1
- package/src/local/local-runtime-target.ts +12 -0
- package/src/main.ts +6 -5
- package/src/mcp/mcp-protocol.ts +2 -2
- package/src/telemetry/apply-events.ts +4 -3
- package/src/telemetry/cli-telemetry.ts +29 -1
- package/src/telemetry/redact.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.53.
|
|
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.
|
|
44
|
-
"@frontera-sdk/functions": "1.53.
|
|
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.
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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:
|
|
678
|
+
schema: definition.schema,
|
|
679
|
+
relation: definition.relation,
|
|
680
|
+
includedColumns: definition.includedColumns,
|
|
633
681
|
})
|
|
634
|
-
} else
|
|
635
|
-
const csv =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/mcp/mcp-protocol.ts
CHANGED
|
@@ -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),
|
|
113
|
-
: errorFields(errorCode((failure.error as { code?: unknown })?.code), failure.error instanceof Error ? failure.error.message : String(failure.error),
|
|
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
|
|
package/src/telemetry/redact.ts
CHANGED
|
@@ -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)
|