@frontera-sdk/cli 1.52.45 → 1.52.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/local/check.ts +57 -11
  33. package/src/commands/local/data.ts +75 -7
  34. package/src/commands/local/dev.ts +77 -9
  35. package/src/commands/local/embeddings.ts +74 -0
  36. package/src/commands/local/index-commands.ts +133 -11
  37. package/src/commands/local/new.ts +78 -0
  38. package/src/commands/local/publish.ts +34 -0
  39. package/src/commands/local/steps.ts +105 -0
  40. package/src/commands/local/sync-request.ts +69 -23
  41. package/src/commands/local/sync.ts +2 -0
  42. package/src/commands/media/index-commands.ts +58 -3
  43. package/src/commands/registry.ts +11 -3
  44. package/src/commands/trace/index-commands.ts +56 -0
  45. package/src/commands/workspace/index-commands.ts +14 -7
  46. package/src/config.ts +4 -1
  47. package/src/dev-broker.ts +15 -1
  48. package/src/device-login.ts +143 -0
  49. package/src/exit.ts +36 -8
  50. package/src/flag-help.ts +30 -0
  51. package/src/forge/claude-adapter.ts +17 -4
  52. package/src/forge/permission-rules.ts +158 -0
  53. package/src/forge/touches.ts +7 -6
  54. package/src/function-outside-imports.ts +13 -2
  55. package/src/function-source.ts +90 -0
  56. package/src/harness.ts +4 -4
  57. package/src/key-reach.ts +64 -0
  58. package/src/local/local-actions.ts +15 -3
  59. package/src/local/local-apply-error.ts +26 -0
  60. package/src/local/local-apps.ts +9 -3
  61. package/src/local/local-blueprint-schemas.ts +15 -1
  62. package/src/local/local-confidential.ts +81 -0
  63. package/src/local/local-data-load.ts +130 -0
  64. package/src/local/local-dev-references.ts +24 -4
  65. package/src/local/local-embedding-setup.ts +62 -0
  66. package/src/local/local-jobs.ts +108 -0
  67. package/src/local/local-new.ts +148 -0
  68. package/src/local/local-org-settings.ts +1 -1
  69. package/src/local/local-person-steps.ts +340 -0
  70. package/src/local/local-personas.ts +16 -5
  71. package/src/local/local-portability.ts +22 -1
  72. package/src/local/local-publish.ts +26 -0
  73. package/src/local/local-request-evidence.ts +35 -6
  74. package/src/local/local-runtime-history.ts +4 -3
  75. package/src/local/local-said-once.ts +51 -0
  76. package/src/local/local-scaffold.ts +40 -2
  77. package/src/local/local-schemas.ts +60 -11
  78. package/src/local/local-stand-ins.ts +44 -0
  79. package/src/local/local-supervisor.ts +22 -0
  80. package/src/local/local-sync-models.ts +5 -2
  81. package/src/local/local-sync-names.ts +3 -0
  82. package/src/local/local-sync-portable.ts +64 -15
  83. package/src/local/local-sync-precheck.ts +30 -1
  84. package/src/local/local-sync-push.ts +81 -8
  85. package/src/local/local-sync-remote.ts +10 -7
  86. package/src/local/local-test-models.ts +173 -0
  87. package/src/local/local-test-runner.ts +232 -25
  88. package/src/local/local-test-scope.ts +174 -0
  89. package/src/local/local-ungranted-reads.ts +55 -0
  90. package/src/local/local-writers.ts +171 -0
  91. package/src/main.ts +75 -17
  92. package/src/mcp/browser/ax-snapshot.ts +13 -1
  93. package/src/mcp/browser/page-scripts.ts +1 -0
  94. package/src/mcp/mcp-conversations.ts +171 -0
  95. package/src/mcp/mcp-server.ts +4 -1
  96. package/src/mcp/mcp-traces.ts +7 -2
  97. package/src/mcp/tools/agent-tools.ts +94 -2
  98. package/src/mcp/tools/project-tools.ts +149 -13
  99. package/src/mcp/tools/test-tools.ts +64 -13
  100. package/src/output.ts +22 -7
  101. package/src/pack.ts +16 -0
  102. package/src/project-context.ts +18 -5
  103. package/src/project.ts +4 -0
  104. package/src/remote-guard.ts +52 -0
  105. package/src/vendor/kit-assets.json +11 -11
@@ -1,3 +1,5 @@
1
+ import { oneLine } from './local-applied'
2
+
1
3
  /**
2
4
  * A failed apply command's error, as `frontera local dev` prints it: the error
3
5
  * line and the detail lines under it (validation issues, the hint), at most
@@ -33,3 +35,27 @@ export function applyErrorText(output: string): string {
33
35
  if (!shown.length) return 'failed with no output'
34
36
  return [shown[0], ...shown.slice(1).map((l) => ` ${l}`)].join('\n')
35
37
  }
38
+
39
+ /** Error lines that say only that something failed, and need the line under them to say why. */
40
+ const GENERIC = /^(invalid workflow definition|blueprint validation is blocked|validation failed|the request is invalid|bad request|invalid definition)\b/i
41
+
42
+ /**
43
+ * A failed item's reason in one line, as `local dev` records it and `local
44
+ * status` and the MCP `apply` say it (Forge improvement plan N9): the error's
45
+ * first line, and when that line only says it failed ("Invalid workflow
46
+ * definition", "Blueprint validation is blocked"), the first line under it
47
+ * that says why: a blocker, a definition error, the hint.
48
+ */
49
+ export function applyFailureReason(output: string): string {
50
+ const text = applyErrorText(output)
51
+ const head = oneLine(text)
52
+ if (!GENERIC.test(head)) return head
53
+ const detail = text
54
+ .split('\n')
55
+ .slice(1)
56
+ .map((l) => l.trim().replace(/^(hint:|✕)\s*/i, '').trim())
57
+ .find((l) => l && !/^frontera\s/.test(l) && !/^`frontera/.test(l) && !/^\(\d+ steps? applied/.test(l))
58
+ if (!detail) return head
59
+ const joined = `${head.replace(/[.:]\s*$/, '')}: ${detail}`
60
+ return joined.length > 300 ? `${joined.slice(0, 297)}…` : joined
61
+ }
@@ -27,6 +27,7 @@ export interface AppRow {
27
27
  slug: string
28
28
  name: string
29
29
  description?: string | null
30
+ icon?: string | null
30
31
  hosting?: 'platform' | 'external' | null
31
32
  version?: string | null
32
33
  manifest?: { routing?: unknown; connectDomains?: unknown; resourceDomains?: unknown } | null
@@ -37,6 +38,8 @@ export interface AppRow {
37
38
  export interface AppFile {
38
39
  name: string
39
40
  description?: string
41
+ /** An icon from the platform's set, by name (X6). */
42
+ icon?: string
40
43
  hosting?: 'external'
41
44
  version?: string
42
45
  routing?: string
@@ -57,6 +60,7 @@ export function appDocument(row: AppRow): AppFile {
57
60
  return {
58
61
  name: row.name,
59
62
  ...(row.description ? { description: row.description } : {}),
63
+ ...(row.icon ? { icon: row.icon } : {}),
60
64
  ...(row.hosting === 'external' ? { hosting: 'external' as const } : {}),
61
65
  ...(row.version ? { version: row.version } : {}),
62
66
  ...(m && typeof m.routing === 'string' && m.routing !== 'spa' ? { routing: m.routing } : {}),
@@ -120,12 +124,14 @@ export async function applyApp(root: string, slug: string, call: Call, provides:
120
124
  }
121
125
  const name = typeof doc.name === 'string' && doc.name.trim() ? doc.name.trim() : slug
122
126
  const description = typeof doc.description === 'string' ? doc.description : null
123
- const ensured = await call('POST', '/v1/platform-apps/ensure', { slug, displayName: name, kind: 'code', ...(description ? { description } : {}) })
127
+ const icon = typeof doc.icon === 'string' && doc.icon.trim() ? doc.icon.trim() : null
128
+ const ensured = await call('POST', '/v1/platform-apps/ensure', { slug, displayName: name, kind: 'code', ...(description ? { description } : {}), ...(icon ? { icon } : {}) })
124
129
  if (ensured.status >= 400) return { ok: false, output: `error [APP]: ${errorOf(ensured)}` }
125
130
  const lines: string[] = []
126
131
  if (!dataOf(ensured.body)?.created) {
127
- const renamed = await call('PATCH', `/v1/platform-apps/${encodeURIComponent(String(dataOf(ensured.body)?.id ?? ''))}`, { displayName: name, description })
128
- if (renamed.status >= 400) lines.push(` warning: Its name and description could not be set here (${errorOf(renamed)})`)
132
+ // A file with no icon leaves the App's as it is.
133
+ const renamed = await call('PATCH', `/v1/platform-apps/${encodeURIComponent(String(dataOf(ensured.body)?.id ?? ''))}`, { displayName: name, description, ...(icon ? { icon } : {}) })
134
+ if (renamed.status >= 400) lines.push(` warning: Its name, description and icon could not be set here (${errorOf(renamed)})`)
129
135
  }
130
136
  const bindings = Array.isArray(doc.functions) ? doc.functions : []
131
137
  const named = new Set<string>()
@@ -495,6 +495,20 @@ const ACTION: JsonSchema = {
495
495
  required: ['invokeCapability', 'approval', 'reason', 'sensitivity'],
496
496
  additionalProperties: false,
497
497
  },
498
+ attachment: {
499
+ type: 'object',
500
+ description:
501
+ 'How a deployment attaches the file the Action takes, for an Action over a Media Set’s records: the input the file arrives in (a staged upload’s id, which `frontera media stage` prints, or a run’s file), and the media reference property it fills, the text property that keeps the item’s id, or both. `local dev` deploys the Action with it on the local runtime. It is a deployment choice, not part of the Action: it stays in this project when the Action is proposed, and in the workspace the person who deploys the Action chooses (`frontera action deploy <apiName> --attach <property> --file-input <input>`).',
502
+ properties: {
503
+ fileInput: { type: 'string', description: 'The input the file arrives in, by apiName: a string input no expected change sets.' },
504
+ property: { type: 'string', description: 'The media reference property the file fills.' },
505
+ itemIdProperty: { type: 'string', description: 'A text property that keeps the item’s id.' },
506
+ mediaSet: { type: 'string', description: 'The Media Set the file joins, by API name; the property’s own set when left out.' },
507
+ },
508
+ required: ['fileInput'],
509
+ anyOf: [{ required: ['property'] }, { required: ['itemIdProperty'] }],
510
+ additionalProperties: false,
511
+ },
498
512
  },
499
513
  required: ['apiName', 'displayName', 'description', 'subject', 'effect', 'inputs', 'businessOutcomes', 'concurrency', 'impact', 'submissionCriteria', 'governance'],
500
514
  }
@@ -521,7 +535,7 @@ const OBJECT_SET: JsonSchema = {
521
535
  $schema: DRAFT,
522
536
  title: 'Blueprint saved object set (blueprint/object-sets/<name>.yaml)',
523
537
  description:
524
- 'A named set of one object type’s records that workflows, Agents and Apps reuse: dynamic (a definition, re-evaluated on every read) or static (a fixed list of primary keys). `frontera local dev` saves each file on the local runtime once the Blueprint is published there, so a reset brings it back. Other files name it `objectSet/<name>`, where the name is the set’s `name` (spaces allowed: `objectSet/Open work orders`) or the file’s name (`objectSet/open-work-orders`).',
538
+ 'A named set of one object type’s records that workflows, Agents and Apps reuse: dynamic (a definition, re-evaluated on every read) or static (a fixed list of primary keys). `frontera local dev` saves each file on the local runtime once the Blueprint is published there, so a reset brings it back; `frontera local sync push` proposes it, and Apply saves it in the workspace after the Blueprint, before the workflows that name it (a static set’s records are made once; a change request does not remove a set). Other files name it `objectSet/<name>`, where the name is the set’s `name` (spaces allowed: `objectSet/Open work orders`) or the file’s name (`objectSet/open-work-orders`).',
525
539
  type: 'object',
526
540
  properties: {
527
541
  name: text('What people call it ("Open work orders"); the file’s name when absent.'),
@@ -0,0 +1,81 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ import { readWorkspaceGrants } from './local-workspace-grants'
6
+
7
+ /**
8
+ * Confidential fields no one in the project may read (Forge improvement plan
9
+ * X7, T14 step 7). A field classified confidential or restricted reads as
10
+ * hidden to an Agent or a workflow without `read_sensitive` on its type; with
11
+ * no Agent's `bindings.json` and no `blueprint/workspace-grants.yaml`
12
+ * granting it, every reader sees it hidden, which reads as a mistake once an
13
+ * Agent says it can't see a lead's email. Said as a warning when the
14
+ * Blueprint applies, never a refusal: keeping a field from everyone may be
15
+ * the point.
16
+ */
17
+ export interface UnreadConfidential {
18
+ objectType: string
19
+ fields: string[]
20
+ }
21
+
22
+ const SENSITIVE = new Set(['confidential', 'restricted'])
23
+
24
+ function readDoc(path: string): Record<string, unknown> | null {
25
+ try {
26
+ const text = readFileSync(path, 'utf8')
27
+ const doc = path.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
28
+ return doc && typeof doc === 'object' && !Array.isArray(doc) ? (doc as Record<string, unknown>) : null
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /** Each object type's confidential or restricted fields, by its own classification or the type's. */
35
+ export function confidentialFields(root: string): Map<string, string[]> {
36
+ const out = new Map<string, string[]>()
37
+ const dir = join(root, 'blueprint', 'object-types')
38
+ if (!existsSync(dir)) return out
39
+ for (const file of readdirSync(dir).sort()) {
40
+ if (!/\.(ya?ml|json)$/.test(file)) continue
41
+ const doc = readDoc(join(dir, file))
42
+ if (!doc || typeof doc.apiName !== 'string') continue
43
+ const typeClass = ((doc.governance as { sensitivity?: { classification?: unknown } } | undefined)?.sensitivity?.classification) ?? 'internal'
44
+ const fields = (Array.isArray(doc.properties) ? doc.properties : []).flatMap((p) => {
45
+ const prop = p as { apiName?: unknown; sensitivity?: unknown }
46
+ const own = typeof prop.sensitivity === 'string' ? prop.sensitivity : typeClass
47
+ return typeof prop.apiName === 'string' && SENSITIVE.has(String(own)) ? [prop.apiName] : []
48
+ })
49
+ if (fields.length) out.set(doc.apiName, fields)
50
+ }
51
+ return out
52
+ }
53
+
54
+ /** The object types something in the project may read in full: an Agent's `read_sensitive`, or the workspace's. */
55
+ export function clearedTypes(root: string): Set<string> {
56
+ const cleared = new Set<string>()
57
+ for (const g of readWorkspaceGrants(root)?.grants ?? []) if (g.permissions.includes('read_sensitive')) cleared.add(g.objectType)
58
+ const agents = join(root, 'agents')
59
+ if (!existsSync(agents)) return cleared
60
+ for (const slug of readdirSync(agents)) {
61
+ const bindings = readDoc(join(agents, slug, 'bindings.json'))
62
+ for (const entry of Array.isArray(bindings?.blueprint) ? bindings!.blueprint : []) {
63
+ const e = entry as { objectType?: unknown; permissions?: unknown }
64
+ if (typeof e.objectType === 'string' && Array.isArray(e.permissions) && e.permissions.includes('read_sensitive')) cleared.add(e.objectType.replace(/^objectType\//, ''))
65
+ }
66
+ }
67
+ return cleared
68
+ }
69
+
70
+ export function unreadConfidential(root: string): UnreadConfidential[] {
71
+ const cleared = clearedTypes(root)
72
+ return [...confidentialFields(root)].filter(([type]) => !cleared.has(type)).map(([objectType, fields]) => ({ objectType, fields }))
73
+ }
74
+
75
+ /** One warning line per object type, as `local dev` prints its others. */
76
+ export function unreadConfidentialLines(found: UnreadConfidential[]): string[] {
77
+ return found.map(({ objectType, fields }) => {
78
+ const shown = fields.length > 4 ? `${fields.slice(0, 4).join(', ')} and ${fields.length - 4} more` : fields.join(', ')
79
+ return ` warning: ${objectType}'s confidential field${fields.length === 1 ? '' : 's'} (${shown}) no Agent or workspace here may read: each reads as hidden. If one should, give it read_sensitive in its bindings.json, or the workspace in blueprint/workspace-grants.yaml`
80
+ })
81
+ }
@@ -0,0 +1,130 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ import { CliError } from '../errors'
6
+
7
+ /**
8
+ * Records loaded through the project's governed Actions (Forge improvement
9
+ * plan L2, T12; P4 2.3: about 45 tool calls and a throwaway workflow).
10
+ * `frontera local data load <file> --via-actions` submits each record as
11
+ * the Action the file names, as the local `submitter` persona, so the
12
+ * records arrive the way the platform makes them: through the Action's
13
+ * contract, its criteria and its effect. The local runtime only.
14
+ *
15
+ * The file (JSON or YAML) is a list of groups, run in its order, so a group
16
+ * whose records name another's comes after it:
17
+ *
18
+ * - action: createCustomer
19
+ * records:
20
+ * - { name: Acme, tier: gold }
21
+ * - action: openTicket
22
+ * records:
23
+ * - input: { title: Broken pump }
24
+ * subject: CUST-1 # for an Action on an existing record
25
+ */
26
+
27
+ export interface LoadGroup {
28
+ action: string
29
+ records: Array<{ input: Record<string, unknown>; subject?: string | number }>
30
+ }
31
+
32
+ export function readLoadFile(path: string): LoadGroup[] {
33
+ let doc: unknown
34
+ try {
35
+ const text = readFileSync(path, 'utf8')
36
+ doc = /\.json$/i.test(path) ? JSON.parse(text) : parseYaml(text)
37
+ } catch (err) {
38
+ throw new CliError(`${path} could not be read: ${(err as Error).message}`, { code: 'USAGE', hint: 'a list of { action, records } (`frontera local data --help`)' })
39
+ }
40
+ if (!Array.isArray(doc)) throw new CliError(`${path} is not a list of { action, records }`, { code: 'USAGE', hint: '- action: createCustomer\n records:\n - { name: Acme }' })
41
+ return doc.map((g, i) => {
42
+ const group = g as { action?: unknown; records?: unknown }
43
+ if (typeof group?.action !== 'string' || !/^[a-z][A-Za-z0-9]*$/.test(group.action)) throw new CliError(`${path}: group ${i + 1} names no Action (\`action: <apiName>\`)`, { code: 'USAGE', hint: '`frontera local schema` describes the file: each group is `action: <apiName>` with its `records` list' })
44
+ if (!Array.isArray(group.records)) throw new CliError(`${path}: group ${i + 1} (${group.action}) has no records list`, { code: 'USAGE', hint: 'list the records under `records:`, one per Action submission' })
45
+ return {
46
+ action: group.action,
47
+ records: group.records.map((r) => {
48
+ const rec = r as { input?: unknown; subject?: unknown }
49
+ const wrapped = rec && typeof rec === 'object' && 'input' in rec && typeof rec.input === 'object' && rec.input !== null
50
+ return wrapped
51
+ ? { input: rec.input as Record<string, unknown>, ...(typeof rec.subject === 'string' || typeof rec.subject === 'number' ? { subject: rec.subject } : {}) }
52
+ : { input: (rec ?? {}) as Record<string, unknown> }
53
+ }),
54
+ }
55
+ })
56
+ }
57
+
58
+ /** The same record of the same file submits once: the key a second load sends is the first's. */
59
+ export function loadKey(file: string, action: string, index: number, record: unknown): string {
60
+ return `load-${createHash('sha256').update(JSON.stringify([file, action, index, record])).digest('hex').slice(0, 32)}`
61
+ }
62
+
63
+ export interface LoadSubmit {
64
+ (action: string, invocation: Record<string, unknown>, idempotencyKey: string): Promise<{ lifecycle?: string; status?: string; requestId?: string; id?: string }>
65
+ }
66
+
67
+ export interface LoadOutcome {
68
+ action: string
69
+ submitted: number
70
+ applied: number
71
+ waiting: number
72
+ /** Submitted, and settled without applying (refused by its criteria, rejected, failed): by lifecycle. */
73
+ notApplied: Array<{ index: number; lifecycle: string }>
74
+ refused: Array<{ index: number; reason: string }>
75
+ }
76
+
77
+ /** What each lifecycle comes to: applied, waiting on a person, or neither. */
78
+ function outcomeOf(answer: { lifecycle?: string; status?: string }): 'applied' | 'waiting' | 'other' {
79
+ const state = String(answer.lifecycle ?? answer.status ?? '')
80
+ if (/not_applied|rejected|failed|cancel|refused|unknown/.test(state)) return 'other'
81
+ if (/succeeded|applied|completed/.test(state)) return 'applied'
82
+ if (/awaiting|pending|ready|dispatch|running|queued/.test(state)) return 'waiting'
83
+ return 'other'
84
+ }
85
+
86
+ /** Submit each group's records in batches, group after group; a refused record is said, not retried. */
87
+ export async function loadViaActions(
88
+ file: string,
89
+ groups: LoadGroup[],
90
+ submit: LoadSubmit,
91
+ opts: { batch?: number; subjectType?: (action: string) => Promise<string | null>; onGroup?: (o: LoadOutcome) => void } = {},
92
+ ): Promise<LoadOutcome[]> {
93
+ const batch = Math.max(1, Math.min(16, opts.batch ?? 4))
94
+ const out: LoadOutcome[] = []
95
+ for (const group of groups) {
96
+ const outcome: LoadOutcome = { action: group.action, submitted: 0, applied: 0, waiting: 0, notApplied: [], refused: [] }
97
+ const subjectTypeId = group.records.some((r) => r.subject !== undefined) ? await opts.subjectType?.(group.action) ?? null : null
98
+ for (let start = 0; start < group.records.length; start += batch) {
99
+ await Promise.all(group.records.slice(start, start + batch).map(async (record, offset) => {
100
+ const index = start + offset
101
+ const invocation: Record<string, unknown> = { input: record.input }
102
+ if (record.subject !== undefined) {
103
+ if (!subjectTypeId) {
104
+ outcome.refused.push({ index, reason: `${group.action} acts on no existing record, or its object type couldn't be read; leave subject out` })
105
+ return
106
+ }
107
+ invocation.subjectRef = { objectTypeId: subjectTypeId, objectId: String(record.subject) }
108
+ }
109
+ try {
110
+ const answer = await submit(group.action, invocation, loadKey(file, group.action, index, record))
111
+ outcome.submitted++
112
+ const kind = outcomeOf(answer)
113
+ if (kind === 'applied') outcome.applied++
114
+ else if (kind === 'waiting') outcome.waiting++
115
+ else outcome.notApplied.push({ index, lifecycle: String(answer.lifecycle ?? answer.status ?? 'unknown') })
116
+ } catch (err) {
117
+ outcome.refused.push({ index, reason: (err as Error).message.replace(/\s+/g, ' ').slice(0, 300) })
118
+ }
119
+ }))
120
+ }
121
+ opts.onGroup?.(outcome)
122
+ out.push(outcome)
123
+ }
124
+ return out
125
+ }
126
+
127
+ export function loadWords(o: LoadOutcome): string {
128
+ const parts = [`${o.applied} applied`, ...(o.waiting ? [`${o.waiting} waiting on a person (an approval step, or still running)`] : []), ...(o.notApplied.length ? [`${o.notApplied.length} not applied (${[...new Set(o.notApplied.map((n) => n.lifecycle))].join(', ')})`] : []), ...(o.refused.length ? [`${o.refused.length} refused`] : [])]
129
+ return `${o.action}: ${o.submitted + o.refused.length} record${o.submitted + o.refused.length === 1 ? '' : 's'}, ${parts.join(', ')}`
130
+ }
@@ -13,9 +13,10 @@ import { applyQualityChecks, QUALITY_CHECKS_FILE, readQualityChecks, sourcePaths
13
13
  import { readLock } from './local-sync-plan'
14
14
  import { applyErrorText } from './local-apply-error'
15
15
  import { localAdaptation } from './local-blueprint-outline'
16
- import { actionFit, classify, objectTypeFit, objectTypeNeeds } from './local-portability'
16
+ import { actionAttachment, actionFit, classify, objectTypeFit, objectTypeNeeds } from './local-portability'
17
17
  import { actionSemanticConflicts, semanticFactsOf } from '../blueprint/action-semantic-fit'
18
18
  import { inheritedConflictLine } from '../blueprint/action-rules'
19
+ import { parseProblem } from '../blueprint/tree'
19
20
  import { inheritedFromPull, unchangedSincePull } from './local-check-inherited'
20
21
  import type { SyncProfiles, SyncTarget } from './local-dev-sync'
21
22
  import type { LocalProject } from './local-project'
@@ -554,6 +555,26 @@ export function resolveBlueprint(project: LocalProject, refused: BlueprintRefusa
554
555
  const mediaSets: Record<string, string> = {}
555
556
  const mediaTypes: Record<string, { set: string; file: string }> = {}
556
557
  const hasLocalDataset = (dataset: string) => existsSync(join(project.root, 'datasets', `${dataset}.json`))
558
+ // A file that doesn't parse is left off, said with its line and column, and the rest of the
559
+ // Blueprint applies (N9): one YAML error used to fail every item, naming no file. What names it
560
+ // is refused by the runtime next, and left off as any refusal is.
561
+ const leftOffActions: Record<string, string> = {}
562
+ const refusedActions: string[] = []
563
+ for (const [folder, part] of [['object-types', null], ['link-types', 'linkType'], ['metrics', 'metric'], ['semantic-types', 'semanticType'], ['shared-fields', 'sharedField'], ['actions', null]] as const) {
564
+ for (const name of yamlIn(folder)) {
565
+ const problem = parseProblem(join('blueprint', folder, name), readFileSync(join(source, folder, name), 'utf8'))
566
+ if (!problem) continue
567
+ const stem = name.replace(/\.ya?ml$/, '')
568
+ removed.push(join(folder, name))
569
+ warnings.push(` warning: ${problem}; it is left off this runtime until it parses`)
570
+ const reason = `the file doesn't parse: ${problem}`
571
+ if (folder === 'object-types') objects[stem] = { status: 'left-off', adaptations: [`is left off: ${reason}`], refused: reason }
572
+ else if (folder === 'actions') {
573
+ leftOffActions[stem] = reason
574
+ refusedActions.push(stem)
575
+ } else parts[`${part}/${stem}`] = { reason, refused: true }
576
+ }
577
+ }
557
578
  for (const name of yamlIn('object-types')) {
558
579
  const doc = read('object-types', name)
559
580
  if (!doc || typeof doc.apiName !== 'string') continue
@@ -630,8 +651,6 @@ export function resolveBlueprint(project: LocalProject, refused: BlueprintRefusa
630
651
  }
631
652
  }
632
653
  const actions: LocalAction[] = []
633
- const leftOffActions: Record<string, string> = {}
634
- const refusedActions: string[] = []
635
654
  // A value an Action offers that the semantic type it writes refuses, in an Action and files still as
636
655
  // pulled: the workspace's, so the apply takes it (`action-rules.ts`), and it is said here as inherited.
637
656
  const inheritedWarnings: Record<string, string[]> = {}
@@ -662,7 +681,8 @@ export function resolveBlueprint(project: LocalProject, refused: BlueprintRefusa
662
681
  continue
663
682
  }
664
683
  const capability = doc.governance?.invokeCapability
665
- actions.push({ apiName: doc.apiName, fit, ...(typeof capability === 'string' ? { invokeCapability: capability } : {}) })
684
+ const attachment = actionAttachment(doc)
685
+ actions.push({ apiName: doc.apiName, fit, ...(typeof capability === 'string' ? { invokeCapability: capability } : {}), ...(attachment ? { attachment } : {}) })
666
686
  const conflicts = semantic && inheritedConflict ? actionSemanticConflicts(doc, semantic) : []
667
687
  if (conflicts.length && conflicts.every((c) => inheritedConflict!(doc.apiName, c.refs))) {
668
688
  inheritedWarnings[doc.apiName] = conflicts.map((c) => inheritedConflictLine(c.message))
@@ -0,0 +1,62 @@
1
+ import { embeddingEquivalents, PLATFORM_DEFAULT_EMBEDDING } from './local-org-settings'
2
+
3
+ /**
4
+ * Real embeddings on the local runtime from a key the person already has
5
+ * (Forge improvement plan L9, T12 step 7). Without one, the runtime embeds
6
+ * with the keyless local vectors, which find passages by shared words, not
7
+ * meaning. `frontera local embeddings` says which providers can embed here,
8
+ * which of them have a key stored, and which have one in this shell's
9
+ * environment (by the variable's name; a value is never read into output),
10
+ * and recommends the one that embeds as the workspace does.
11
+ * `frontera local embeddings use <provider>` stores that provider's key from
12
+ * the environment, as `local key-set` would from a prompt.
13
+ */
14
+
15
+ /** The providers whose key gives the runtime an embedding model, and the variables a key for each is usually kept in. */
16
+ export const EMBEDDING_KEY_SOURCES: ReadonlyArray<{ provider: 'openai' | 'openrouter' | 'google'; label: string; vars: readonly string[] }> = [
17
+ { provider: 'openai', label: 'OpenAI', vars: ['OPENAI_API_KEY'] },
18
+ { provider: 'openrouter', label: 'OpenRouter', vars: ['OPENROUTER_API_KEY'] },
19
+ { provider: 'google', label: 'Google Gemini', vars: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
20
+ ]
21
+
22
+ export interface EmbeddingKeyOption {
23
+ provider: 'openai' | 'openrouter' | 'google'
24
+ label: string
25
+ stored: boolean
26
+ /** The environment variable that holds a key for it, when one does; never its value. */
27
+ envVar: string | null
28
+ /** Whether it embeds with the workspace's model (or the platform's default when the project isn't linked). */
29
+ matchesWorkspace: boolean
30
+ }
31
+
32
+ type Embedding = { provider: string | null; model: string | null }
33
+
34
+ /** Each provider that could embed here, with where a key for it is. */
35
+ export function embeddingKeyOptions(env: Record<string, string | undefined>, stored: readonly string[], workspace: Embedding | null = PLATFORM_DEFAULT_EMBEDDING): EmbeddingKeyOption[] {
36
+ const same = new Set(workspace ? embeddingEquivalents(workspace).map((m) => m.provider) : [])
37
+ return EMBEDDING_KEY_SOURCES.map((s) => ({
38
+ provider: s.provider,
39
+ label: s.label,
40
+ stored: stored.includes(s.provider),
41
+ envVar: s.vars.find((v) => !!env[v]?.trim()) ?? null,
42
+ matchesWorkspace: same.has(s.provider),
43
+ }))
44
+ }
45
+
46
+ /** The provider to use: a key already stored first, then one in the environment; one that embeds as the workspace does before one that doesn't. */
47
+ export function recommendedEmbeddingKey(options: readonly EmbeddingKeyOption[]): EmbeddingKeyOption | null {
48
+ const usable = options.filter((o) => o.stored || o.envVar)
49
+ const rank = (o: EmbeddingKeyOption) => (o.matchesWorkspace ? 0 : 2) + (o.stored ? 0 : 1)
50
+ return [...usable].sort((a, b) => rank(a) - rank(b))[0] ?? null
51
+ }
52
+
53
+ /** What `local embeddings` says to do next. */
54
+ export function embeddingNextStep(options: readonly EmbeddingKeyOption[], keyless: boolean): string {
55
+ const best = recommendedEmbeddingKey(options)
56
+ const restart = '`frontera local stop`, then `frontera local start`; the next `frontera local dev` sets each knowledge base’s threshold for that model'
57
+ if (!keyless) return 'the runtime already embeds with a provider’s model'
58
+ if (!best) return `no OpenAI, OpenRouter or Gemini key is stored or in this shell: store one with \`frontera local key-set openai\` (or openrouter, google), then ${restart}`
59
+ const why = best.matchesWorkspace ? 'it embeds as the workspace does' : 'the workspace embeds with another model, so retrieval here ranks a little differently'
60
+ if (best.stored) return `a ${best.label} key is stored but the runtime started before it: ${restart} (${why})`
61
+ return `${best.envVar} in this shell holds a ${best.label} key: \`frontera local embeddings use ${best.provider}\` stores it, then ${restart} (${why})`
62
+ }
@@ -0,0 +1,108 @@
1
+ import { containerState, followLogs, startContainer, stopContainer } from './local-docker'
2
+ import type { LocalProject } from './local-project'
3
+ import { waitForHttp } from './local-process'
4
+ import { recordRuntime } from './local-runtime-history'
5
+ import { logPath, type LocalCredentials, type LocalState } from './local-state'
6
+
7
+ /**
8
+ * Restarting the local runtime's jobs engine on its own (Forge improvement
9
+ * plan X2, T12; P4 2.4). The engine crashed twice while `local status` said it
10
+ * was running, its lost runs held the queue, and the only way back was
11
+ * restarting the whole runtime, which stopped the App dev servers too.
12
+ *
13
+ * `frontera local restart jobs`, and the supervisor when the engine stops
14
+ * answering, restart its container alone: the service, Studio and every App
15
+ * dev server keep running. The engine registers the service's functions again
16
+ * as it starts. It keeps runs in memory, so each run still open from before
17
+ * has no executor any more: they are marked failed on the runtime
18
+ * (`POST /v1/local/jobs/lost-runs`), saying why, so they stop holding the queue.
19
+ */
20
+
21
+ export interface JobsRestart {
22
+ ms: number
23
+ /** Runs it lost, marked failed; null when they couldn't be (the reason is in `note`). */
24
+ lost: number | null
25
+ note?: string
26
+ }
27
+
28
+ export interface JobsDeps {
29
+ stop: (name: string) => void
30
+ start: (name: string) => void
31
+ state: (name: string) => ReturnType<typeof containerState>
32
+ answers: (url: string, timeoutMs: number) => Promise<boolean>
33
+ fetcher: typeof fetch
34
+ now: () => Date
35
+ /** Follow the restarted container's logs into the jobs log again: the old follow ended with it. */
36
+ follow: (name: string, logFile: string) => void
37
+ }
38
+
39
+ const defaultDeps: JobsDeps = { stop: stopContainer, start: startContainer, state: containerState, answers: (url, ms) => waitForHttp(url, ms), fetcher: fetch, now: () => new Date(), follow: followLogs }
40
+
41
+ /** The jobs engine's container and port, from the runtime's state; null when the runtime runs it off. */
42
+ export function jobsComponent(project: LocalProject, state: LocalState): { container: string; port: number } | null {
43
+ const c = state.components.find((x) => x.name === 'jobs')
44
+ if (!c || c.status === 'off') return null
45
+ return { container: c.container ?? `frontera-${project.id}-jobs`, port: state.ports.jobs }
46
+ }
47
+
48
+ /** Mark the runs the engine lost failed, on the runtime, with its organization key. */
49
+ export async function failLostRuns(serviceUrl: string, creds: LocalCredentials, startedBefore: Date, fetcher: typeof fetch = fetch): Promise<{ lost: number | null; note?: string }> {
50
+ try {
51
+ const res = await fetcher(new URL('/v1/local/jobs/lost-runs', serviceUrl), {
52
+ method: 'POST',
53
+ headers: { authorization: `Bearer ${creds.orgKey}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId, 'content-type': 'application/json' },
54
+ body: JSON.stringify({ startedBefore: startedBefore.toISOString() }),
55
+ signal: AbortSignal.timeout(30_000),
56
+ })
57
+ if (res.status === 404) return { lost: null, note: 'this runtime is older than lost-run cleanup, so runs it had open stay open: `frontera local start --update`' }
58
+ if (!res.ok) return { lost: null, note: `the runs it had open couldn't be marked failed: ${res.status} ${(await res.text()).slice(0, 160)}` }
59
+ const body = (await res.json()) as { data?: { failed?: number } }
60
+ return { lost: body.data?.failed ?? 0 }
61
+ } catch (err) {
62
+ return { lost: null, note: `the runs it had open couldn't be marked failed: ${(err as Error).message}` }
63
+ }
64
+ }
65
+
66
+ /** Restart the jobs engine alone, wait for it, and fail the runs it lost. */
67
+ export async function restartJobs(project: LocalProject, state: LocalState, why: string, deps: Partial<JobsDeps> = {}): Promise<JobsRestart> {
68
+ const d = { ...defaultDeps, ...deps }
69
+ const jobs = jobsComponent(project, state)
70
+ if (!jobs) throw new Error('this runtime runs without its jobs engine (`frontera local start` with jobs on brings it)')
71
+ if (d.state(jobs.container) === 'missing') throw new Error('its container is gone: `frontera local start` brings the runtime back with its data')
72
+ const started = d.now()
73
+ if (d.state(jobs.container) === 'running') d.stop(jobs.container)
74
+ d.start(jobs.container)
75
+ d.follow(jobs.container, logPath(project, 'jobs'))
76
+ const url = `http://127.0.0.1:${jobs.port}`
77
+ if (!(await d.answers(`${url}/health`, 45_000)) && !(await d.answers(`${url}/`, 5_000))) throw new Error('the jobs engine did not answer within 50 s after restarting: `frontera local logs jobs` says why')
78
+ const ms = d.now().getTime() - started.getTime()
79
+ const { lost, note } = state.credentials ? await failLostRuns(`http://127.0.0.1:${state.ports.service}`, state.credentials, started, d.fetcher) : { lost: null, note: 'the runtime has no credentials yet, so runs it had open stay open' }
80
+ recordRuntime(project, { event: 'jobs-restarted', at: d.now().toISOString(), detail: `${why}${lost ? `; ${lost} run${lost === 1 ? '' : 's'} it had open marked failed` : ''}` })
81
+ return { ms, lost, ...(note ? { note } : {}) }
82
+ }
83
+
84
+ /**
85
+ * The supervisor's watch on the jobs engine: every `everyMs`, its port is
86
+ * probed; after `misses` probes in a row with no answer it is restarted
87
+ * alone, and the runs it lost failed. Returns the timer's stop.
88
+ */
89
+ export function watchJobs(opts: { probe: () => Promise<boolean>; restart: () => Promise<unknown>; everyMs?: number; misses?: number; log?: (line: string) => void }): () => void {
90
+ let missed = 0
91
+ let busy = false
92
+ const timer = setInterval(async () => {
93
+ if (busy) return
94
+ busy = true
95
+ try {
96
+ if (await opts.probe()) missed = 0
97
+ else if (++missed >= (opts.misses ?? 2)) {
98
+ missed = 0
99
+ opts.log?.('the jobs engine stopped answering: restarting it alone')
100
+ await opts.restart().catch((err) => opts.log?.(`restarting the jobs engine failed: ${(err as Error).message}`))
101
+ }
102
+ } finally {
103
+ busy = false
104
+ }
105
+ }, opts.everyMs ?? 30_000)
106
+ timer.unref?.()
107
+ return () => clearInterval(timer)
108
+ }