@frontera-sdk/cli 1.53.8 → 1.53.10

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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +1 -1
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -11,7 +11,8 @@ import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/l
11
11
  import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
12
12
  import { localProject, type LocalProject } from '../../local/local-project'
13
13
  import { normalizeOrigin, syncClient, type SyncClient } from '../../local/local-sync-client'
14
- import { getSyncKey, projectFile, projectKeyFingerprints, readLink, registerTargetProfile, removeSyncKey, removeTargetProfile, setSyncKey, targetProfileName, writeLink, type SyncLink } from '../../local/local-sync-link'
14
+ import { getSyncKey, organizationReader, projectFile, projectKeyFingerprints, readLink, registerTargetProfile, removeSyncKey, removeTargetProfile, setSyncKey, targetProfileName, targetSession, writeLink, type SyncLink } from '../../local/local-sync-link'
15
+ import { signInStep } from '../../device-login'
15
16
  import { describeRepair, repairBases, type BaseRepair } from '../../local/local-sync-base'
16
17
  import { conflictsId, mergeItemFiles, type Choice, type Conflict, type Side } from '../../local/local-sync-merge'
17
18
  import {
@@ -106,14 +107,16 @@ function needLink(project: LocalProject): SyncLink {
106
107
  return link
107
108
  }
108
109
 
109
- export function sourceFor(project: LocalProject, link: SyncLink): SyncSource {
110
+ /** The project's readers: the workspace's with its key, and the organization's (`organizationReader`) when there is one. */
111
+ export async function sourceFor(project: LocalProject, link: SyncLink): Promise<SyncSource> {
110
112
  const key = getSyncKey(project, 'workspace')
111
113
  if (!key) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (the link is in frontera.yaml; keys are per machine)' })
112
- const org = getSyncKey(project, 'organization')
114
+ const { client, notSteward } = await organizationReader(project, link)
113
115
  return {
114
116
  workspace: syncClient(link.platform, key),
115
- organization: org ? syncClient(link.platform, org) : undefined,
117
+ ...(client ? { organization: client } : {}),
116
118
  workspaceId: link.workspace,
119
+ ...(notSteward ? { notSteward } : {}),
117
120
  }
118
121
  }
119
122
 
@@ -186,7 +189,7 @@ export async function settleConflicts(project: LocalProject, link: SyncLink, sna
186
189
 
187
190
  /** Fetch into the snapshot with the project's keys, then bring conflicted items up to the newer version. */
188
191
  export async function fetchFor(project: LocalProject, link: SyncLink, opts: { full?: boolean } = {}): Promise<FetchResult & { conflicts: ConflictsSettled }> {
189
- const source = sourceFor(project, link)
192
+ const source = await sourceFor(project, link)
190
193
  const result = await fetchWorkspace(project.root, source, { blueprint: link.blueprint, full: opts.full })
191
194
  await nameLink(project, link, source)
192
195
  return { ...result, conflicts: await settleConflicts(project, link, result.snapshot, source) }
@@ -215,12 +218,8 @@ function snapshotFor(project: LocalProject, link: SyncLink): RemoteSnapshot | nu
215
218
  }
216
219
 
217
220
  /** The platform with the project's keys, or nothing when this machine has no workspace key. */
218
- function sourceIfKeyed(project: LocalProject, link: SyncLink): SyncSource | undefined {
219
- try {
220
- return sourceFor(project, link)
221
- } catch {
222
- return undefined
223
- }
221
+ function sourceIfKeyed(project: LocalProject, link: SyncLink): Promise<SyncSource | undefined> {
222
+ return sourceFor(project, link).catch(() => undefined)
224
223
  }
225
224
 
226
225
  /**
@@ -250,7 +249,7 @@ export async function planFor(project: LocalProject, link: SyncLink, opts: { kin
250
249
  if (opts.drafts?.size) {
251
250
  const lock = readLock(project.root)
252
251
  const base = lock?.workspace === link.workspace ? lock : null
253
- const remote = await readWorkspace(sourceFor(project, link), { kinds: opts.kinds, drafts: opts.drafts, blueprint: link.blueprint })
252
+ const remote = await readWorkspace(await sourceFor(project, link), { kinds: opts.kinds, drafts: opts.drafts, blueprint: link.blueprint })
254
253
  return { remote, plan: computePlan(project.root, remote.items, base, local, { base: readFromBase, directory: remote.directory }), lock, snapshot: null, fetched: null, bases: noBases }
255
254
  }
256
255
  let snapshot = snapshotFor(project, link)
@@ -272,7 +271,7 @@ export async function planFor(project: LocalProject, link: SyncLink, opts: { kin
272
271
  const byRef = new Map(items.map((i) => [i.ref, i]))
273
272
  const targets = plan.flatMap((p) => (p.status === 'conflict' || p.status === 'unresolved') && base.items[p.ref] ? [{ ref: p.ref, entry: base.items[p.ref]!, remote: byRef.get(p.ref)?.files }] : [])
274
273
  if (targets.length) {
275
- bases = await repairBases(project.root, targets, { source: opts.repair === 'online' ? sourceIfKeyed(project, link) : undefined, capabilities: snapshot.capabilities, directory: directoryOf(snapshot) })
274
+ bases = await repairBases(project.root, targets, { source: opts.repair === 'online' ? await sourceIfKeyed(project, link) : undefined, capabilities: snapshot.capabilities, directory: directoryOf(snapshot) })
276
275
  if (bases.some((b) => b.restored.length)) plan = computePlan(project.root, items, base, local, planOpts)
277
276
  }
278
277
  }
@@ -457,16 +456,19 @@ const connect = async (ctx: CommandContext) => {
457
456
  else if (!keepsOrganizationKey) removeSyncKey(project, 'organization')
458
457
  writeLink(project.root, link)
459
458
  // The workspace as a CLI profile: a change request's steps for a person name it (`--profile`).
460
- const { profile, profileError } = await registerTargetProfile(project, link, previousKeys).then(
459
+ const { profile, profileError } = await registerTargetProfile(project, link, previousKeys, me.orgId).then(
461
460
  (name) => ({ profile: name, profileError: null }),
462
461
  (err: Error) => ({ profile: null, profileError: err.message }),
463
462
  )
463
+ // A change request is applied as the person signed in for this profile; no organization key needs to be here.
464
+ const signIn = profile ? await signInStep({ profile, apiUrl: link.platform, named: true }) : null
464
465
  return {
465
- data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) }, profile },
466
+ data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) }, profile, signIn },
466
467
  text: [
467
468
  `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(platform).host}.`,
468
469
  'Keys are in your keychain; the link is in frontera.yaml.',
469
470
  ...(profile ? [`A change request's steps for a person reach this platform with \`--profile ${profile}\`, not the local runtime.`] : []),
471
+ ...(signIn ? [`Change requests you apply, and the steps they leave, act as you: sign in first with \`${signIn}\`.`] : []),
470
472
  ...(profileError ? [`No CLI profile was recorded for this platform (${profileError}); a change request's steps for a person name none until one is.`] : []),
471
473
  'See what you can pull: `frontera local sync pull --preview`.',
472
474
  ].join('\n'),
@@ -538,8 +540,10 @@ const status = async (ctx: CommandContext) => {
538
540
  ].filter(Boolean)
539
541
  : []
540
542
  const fetchedAt = snapshot?.fetchedAt ?? null
541
- // Governed Actions are read only with an organization key (§15.1): ask for one when the workspace uses them.
542
- const organizationKeyFor = !keys.organization && usesGovernedActions(snapshot) ? 'actions' : null
543
+ // Governed Actions are read at organization grain (§15.1): as the person signed in for the target, or with an organization key.
544
+ const person = await targetSession(project, link)
545
+ const signIn = person || !keys.workspace ? null : await signInStep({ profile: targetProfileName(project, link), apiUrl: link.platform, named: true })
546
+ const organizationKeyFor = !keys.organization && !person && usesGovernedActions(snapshot) ? 'actions' : null
543
547
  // The project's change request, as Forge's card line says it.
544
548
  const kept = currentRequest(readRequests(project.root, link.workspace))
545
549
  // How the last `local dev` left the project, counted as Forge's home counts it.
@@ -547,18 +551,19 @@ const status = async (ctx: CommandContext) => {
547
551
  const mcp = mcpServersNotCarried(project.root)
548
552
  const request = kept ? `${kept.state === 'merged' ? 'Applied' : kept.applied && !kept.applied.ok && kept.state !== 'closed' ? 'Apply failed' : kept.state === 'closed' ? 'Closed' : 'Proposed'} · ${plural(kept.refs.length, 'item')} · ${kept.title}` : null
549
553
  return {
550
- data: { link, keys, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build, notCarried: mcp ? { mcpServers: mcp.files, save: mcp.command } : null },
554
+ data: { link, keys, signedIn: person ? { email: person.email ?? null, until: person.expiresAt } : null, signIn, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build, notCarried: mcp ? { mcpServers: mcp.files, save: mcp.command } : null },
551
555
  text: [
552
556
  `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(link.platform).host}`,
553
557
  ` Last pull ${lastPull ? when(lastPull) : 'never'}`,
554
558
  ` Fetched ${fetchedAt ? when(fetchedAt) : 'never: run `frontera local sync fetch`'}`,
555
559
  ` Keys workspace ${keys.workspace ? 'stored' : 'missing'}, organization ${keys.organization ? 'stored' : 'none'}`,
560
+ ` Signed in ${person ? `as ${person.email ?? 'you'}, until ${person.expiresAt}` : signIn ? `no: \`${signIn}\`` : 'no'}`,
556
561
  tally ? ` Workspace ${parts.length ? parts.join(', ') : 'matches the project'}` : ` Workspace ${error ?? 'not fetched yet'}`,
557
562
  ...(tally && error ? [` Fetch ${error}`] : []),
558
563
  ...(snapshot?.full ? [' Platform older: fast checks aren’t available, so each fetch reads the whole workspace'] : []),
559
564
  ...(request ? [` Request ${request}`] : []),
560
565
  ...(build ? [` Build ${buildStatusText(build)}`] : []),
561
- ...(organizationKeyFor ? [' Actions this workspace’s workflows use governed Actions: run `frontera local sync connect` again with an organization key to bring them'] : []),
566
+ ...(organizationKeyFor ? [` Actions this workspace’s workflows use governed Actions: ${signIn ? `sign in with \`${signIn}\`` : 'connect this computer, then sign in,'} to bring them`] : []),
562
567
  ...(mcp ? [` MCP servers ${mcp.files.join(', ')}: not in a change request; save with \`${mcp.command}\``] : []),
563
568
  ].join('\n'),
564
569
  }
@@ -575,7 +580,7 @@ const pull = async (ctx: CommandContext) => {
575
580
  // A conflicted item whose workspace version moved by a fetch that did not settle it (an older CLI's):
576
581
  // settle it before pulling, and plan again. A preview writes nothing, so it only plans.
577
582
  if (snapshot && !flagBool(ctx, 'preview')) {
578
- const settled = await settleConflicts(project, link, snapshot, repair === 'online' ? sourceIfKeyed(project, link) : undefined)
583
+ const settled = await settleConflicts(project, link, snapshot, repair === 'online' ? await sourceIfKeyed(project, link) : undefined)
579
584
  if (settled.refreshed.length || settled.cleared.length) ({ remote, plan, lock, snapshot, bases } = await planFor(project, link, { kinds, drafts, fetch: 'never', repair }))
580
585
  }
581
586
  if (!plan) throw new CliError('nothing fetched from the workspace yet', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch`, or pull without --offline' })
@@ -670,7 +675,7 @@ const pull = async (ctx: CommandContext) => {
670
675
  if (!noDocuments) for (const r of pulled) if (r.startsWith('knowledge/') && !documents.includes(r.slice('knowledge/'.length))) documents.push(r.slice('knowledge/'.length))
671
676
  const docs: Record<string, DocumentsPulled> = {}
672
677
  if (documents.length) {
673
- const client = sourceFor(project, link).workspace
678
+ const client = (await sourceFor(project, link)).workspace
674
679
  for (const n of documents) {
675
680
  const id = byRef.get(`knowledge/${n}`)?.id
676
681
  docs[n] = id ? await pullDocuments(project.root, client, { name: n, id }) : { written: [], present: [], skipped: [], failed: ['its id is not in the fetched snapshot: fetch again'] }
@@ -689,7 +694,7 @@ const pull = async (ctx: CommandContext) => {
689
694
  return it?.id && version && (replaceEarlierPull || !existsSync(join(project.root, 'apps', slug))) ? [{ slug, id: it.id, version, ...(replaceEarlierPull ? { replaceEarlierPull } : {}) }] : []
690
695
  })
691
696
  if (wantCode.length) {
692
- const client = sourceFor(project, link).workspace
697
+ const client = (await sourceFor(project, link)).workspace
693
698
  for (const app of wantCode) appCode.push(await pullAppCode(project.root, client, app))
694
699
  }
695
700
  }
@@ -709,7 +714,7 @@ const pull = async (ctx: CommandContext) => {
709
714
  const heldItems = remote.items.filter((i) => held.has(i.ref))
710
715
  const needs = connectionsNeeded(heldItems)
711
716
  const documentation = documentationNeeded(heldItems)
712
- const standIns = needs.length ? await writeStandIns(project.root, needs, fetchMode === 'never' ? undefined : sourceIfKeyed(project, link)?.workspace, documentation) : { written: [], replaced: [] }
717
+ const standIns = needs.length ? await writeStandIns(project.root, needs, fetchMode === 'never' ? undefined : (await sourceIfKeyed(project, link))?.workspace, documentation) : { written: [], replaced: [] }
713
718
  // The workspace's analytics stays on the platform: declared, so the build says it on each Agent that uses it.
714
719
  const platformOnly = writePlatformOnly(project.root, analyticsNeeded(heldItems))
715
720
  const connectionStandIns = standIns.written.filter((n) => !documentation.has(n))
@@ -723,7 +728,7 @@ const pull = async (ctx: CommandContext) => {
723
728
  let lessons: LessonsPulled | null = null
724
729
  if (flagBool(ctx, 'lessons')) {
725
730
  const agents = Object.keys(readLock(project.root)?.items ?? {}).flatMap((r) => (r.startsWith('agent/') ? [r.slice('agent/'.length)] : []))
726
- lessons = await pullLessons(project.root, link.workspace, sourceFor(project, link).workspace, agents)
731
+ lessons = await pullLessons(project.root, link.workspace, (await sourceFor(project, link)).workspace, agents)
727
732
  }
728
733
  const lessonLines = !flagBool(ctx, 'lessons')
729
734
  ? []
@@ -128,6 +128,11 @@ export async function readDeviceSession(store: CredentialStore, profile: string,
128
128
  }
129
129
  }
130
130
 
131
+ /** The browser sign-in, for one profile when named. */
132
+ export function signInCommand(profile?: string): string {
133
+ return `frontera auth login --browser${profile ? ` --profile ${profile}` : ''}`
134
+ }
135
+
131
136
  /** How long a stored session must still last for a printed sign-in step to be left out: the steps after it take a few minutes. */
132
137
  const SIGN_IN_STEP_MARGIN_MS = 5 * 60_000
133
138
 
@@ -143,7 +148,7 @@ export async function signInStep(
143
148
  now = Date.now(),
144
149
  ): Promise<string | null> {
145
150
  if (at.profile && (await readDeviceSession(store, at.profile, at.apiUrl, now + SIGN_IN_STEP_MARGIN_MS))) return null
146
- return `frontera auth login --browser${at.named && at.profile ? ` --profile ${at.profile}` : ''}`
151
+ return signInCommand(at.named ? at.profile : undefined)
147
152
  }
148
153
 
149
154
  /** Revoke the session on the platform (best effort) and forget it here. */
@@ -261,6 +261,18 @@ async function deployOne(call: Call, published: Deployable, attachment?: LocalAc
261
261
  await call('org', 'GET', `/v1/blueprint/object-types/${encodeURIComponent(subject)}`),
262
262
  )
263
263
  if (!shape?.objectType?.id) return `${subject} is not published here`
264
+ // Each record the Action writes beside its subject, read the same way.
265
+ const relatedTypes: ObjectTypeShape[] = []
266
+ for (const record of definition.records ?? []) {
267
+ const name = (Array.isArray(types) ? types : []).find((t) => t.id === record.objectTypeId)?.apiName
268
+ const related = name
269
+ ? data<{ objectType?: { id?: string; apiName?: string; primaryKeyPropertyId?: string | null }; properties?: ObjectTypeShape['properties'] }>(
270
+ await call('org', 'GET', `/v1/blueprint/object-types/${encodeURIComponent(name)}`),
271
+ )
272
+ : null
273
+ if (!name || !related?.objectType?.id) return `the object type of its record ${record.key} is not published here`
274
+ relatedTypes.push({ id: related.objectType.id, apiName: related.objectType.apiName ?? name, primaryKeyPropertyId: related.objectType.primaryKeyPropertyId ?? null, properties: related.properties ?? [] })
275
+ }
264
276
  // A Media Set named in the file is found by its API name among this runtime's sets (the stand-ins included).
265
277
  let mediaSetId: string | undefined
266
278
  if (attachment?.mediaSet) {
@@ -273,7 +285,7 @@ async function deployOne(call: Call, published: Deployable, attachment?: LocalAc
273
285
  : undefined
274
286
  let plan
275
287
  try {
276
- plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] }, choice)
288
+ plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] }, choice, relatedTypes)
277
289
  } catch (err) {
278
290
  if (err instanceof PlanDerivationError) return err.message
279
291
  throw err
@@ -555,7 +555,7 @@ export function buildView(project: LocalProject, organizationId: string | null):
555
555
  // Held back for its own reason (the apply watchdog): its words, not a missing dependency.
556
556
  if (entry.error) return entry.error
557
557
  const n = entry.unnamedActions ?? 0
558
- return `Runs ${n === 1 ? 'a governed Action' : `${n} governed Actions`} named only by the platform’s id: add an organization key and pull it again`
558
+ return `Runs ${n === 1 ? 'a governed Action' : `${n} governed Actions`} named only by the platform’s id: sign in for the project’s workspace and pull it again`
559
559
  }
560
560
  return `Needs ${needWords(needs[0]!, depth)}${needs.length > 1 ? ` (and ${needs.length - 1} more)` : ''}`
561
561
  }
@@ -100,6 +100,16 @@ const OBJECT_TYPE: JsonSchema = {
100
100
  sharedField: { anyOf: [ref('The shared field it implements', 'shared-field'), { type: 'null' }], description: 'The shared field it implements, by apiName; null detaches it.' },
101
101
  semanticType: { anyOf: [ref('The semantic type it carries', 'semantic-type'), { type: 'null' }], description: 'The semantic type its values follow (allowed values, bounds), by apiName; null detaches it.' },
102
102
  calculation: { type: 'object', description: 'A calculated field. A location names its latitude and longitude fields: { kind: location, latitude, longitude }.' },
103
+ renamedFrom: {
104
+ type: 'string',
105
+ pattern: '^[a-z][A-Za-z0-9]{0,99}$',
106
+ description: 'This property is the one named <renamedFrom> until now: `frontera blueprint apply` and a change request rename it, keeping its id, its column, its edited values, its editability and every Action, link and metric that names it. Without it, a property removed and another added in one edit are refused, since a rename and a replacement look the same. `frontera blueprint rename property <ObjectType> <old> <new>` writes it and changes the files that name the old name. Kept after the rename, it asks for nothing.',
107
+ },
108
+ replaces: {
109
+ type: 'string',
110
+ pattern: '^[a-z][A-Za-z0-9]{0,99}$',
111
+ description: 'This new property replaces <replaces>, which the file no longer lists: the old property is removed and its values (including values Actions wrote) are not carried over. Use renamedFrom to keep them.',
112
+ },
103
113
  sensitivity: {
104
114
  anyOf: [{ enum: ['public', 'internal', 'confidential', 'restricted'] }, { type: 'null' }],
105
115
  description: "The field's own classification, when it differs from its object type's (`governance.sensitivity.classification`), which every field without one takes: lower for a key that only identifies a record (a `confidential` Customer's `customerId` as `internal`, so references to it resolve), higher for one field of personal data. Leave it out, or `null`, to take the object type's. A location takes its latitude's and longitude's and declares none. Set by a Blueprint Steward, as the object type's classification is.",
@@ -313,7 +323,7 @@ const ACTION_FIELD: JsonSchema = {
313
323
  displayName: text(),
314
324
  description: text(),
315
325
  valueType: ACTION_VALUE_TYPE,
316
- required: { type: 'boolean' },
326
+ required: { type: 'boolean', description: 'An input with required: false and no default may be left out of a request, which keeps the property it sets as it is, or sent as null, which empties that property (not set, on a create). null is refused on a required input, on one with a default, on one that sets a property its object type requires, and on an Action that writes to another system.' },
317
327
  default: { type: ['string', 'number', 'boolean', 'null'], description: 'A JSON scalar that fits valueType. Only on a field with required: false: a required field cannot carry a default.' },
318
328
  sensitivity: SENSITIVITY,
319
329
  },
@@ -323,7 +333,7 @@ const ACTION_FIELD: JsonSchema = {
323
333
 
324
334
  /** A value an expected change sets: an input or output by name, or a constant. */
325
335
  const CHANGE_VALUE: JsonSchema = {
326
- description: '{ kind: input, field: <input apiName> } (the usual), or { kind: literal, value: <scalar> } for a constant ("move to assigned": the platform writes it from the published contract). An Action needs at least one property set from an input besides its key, a constant never sets the key, and a property is set one way, not both. { kind: output } is part of the format, but a write over the Blueprint’s records does not deploy with it.',
336
+ description: '{ kind: input, field: <input apiName> } (the usual), or { kind: literal, value: <scalar> } for a constant ("move to assigned": the platform writes it from the published contract; null empties the property, never one its object type requires). An Action needs at least one property set from an input besides its key, a constant never sets the key, and a property is set one way, not both. { kind: output } is part of the format, but a write over the Blueprint’s records does not deploy with it.',
327
337
  oneOf: [
328
338
  { title: 'input', type: 'object', properties: { kind: { const: 'input' }, field: { type: 'string', pattern: MEMBER_NAME, description: 'The input’s apiName.' } }, required: ['kind', 'field'], additionalProperties: false },
329
339
  { title: 'output', type: 'object', properties: { kind: { const: 'output' }, field: { type: 'string', pattern: MEMBER_NAME, description: 'The outcome output’s apiName.' } }, required: ['kind', 'field'], additionalProperties: false },
@@ -332,6 +342,11 @@ const CHANGE_VALUE: JsonSchema = {
332
342
  }
333
343
 
334
344
  const ON_TYPE = ref('The object type it changes (the subject’s, for an edit or create)', 'object-type')
345
+ const ON_RECORD: JsonSchema = {
346
+ type: 'string',
347
+ pattern: '^[a-z][a-z0-9_]{0,99}$',
348
+ description: 'The record it changes: one of the Action’s records, by key. Leave it out for the subject.',
349
+ }
335
350
 
336
351
  /** One expected change, by kind; each names its object type. */
337
352
  const EXPECTED_CHANGE: JsonSchema = {
@@ -340,12 +355,12 @@ const EXPECTED_CHANGE: JsonSchema = {
340
355
  title: 'property_set',
341
356
  type: 'object',
342
357
  description: 'Sets one property, which the object type’s backing.editable must list; the input’s kind fits the property’s type (a string property takes string or object_reference, number takes number, date date, timestamp timestamp, boolean boolean, and only a geopoint property takes geopoint). A create never sets its primary key (the record is filed under its key input), a delete sets nothing, and each input feeds one property.',
343
- properties: { kind: { const: 'property_set' }, objectType: ON_TYPE, property: text('The property it sets, by apiName.'), value: CHANGE_VALUE },
358
+ properties: { kind: { const: 'property_set' }, objectType: ON_TYPE, property: text('The property it sets, by apiName.'), value: CHANGE_VALUE, record: ON_RECORD },
344
359
  required: ['kind', 'objectType', 'property', 'value'],
345
360
  additionalProperties: false,
346
361
  },
347
- { title: 'object_created', type: 'object', properties: { kind: { const: 'object_created' }, objectType: ON_TYPE }, required: ['kind', 'objectType'], additionalProperties: false },
348
- { title: 'object_deleted', type: 'object', properties: { kind: { const: 'object_deleted' }, objectType: ON_TYPE }, required: ['kind', 'objectType'], additionalProperties: false },
362
+ { title: 'object_created', type: 'object', properties: { kind: { const: 'object_created' }, objectType: ON_TYPE, record: ON_RECORD }, required: ['kind', 'objectType'], additionalProperties: false },
363
+ { title: 'object_deleted', type: 'object', properties: { kind: { const: 'object_deleted' }, objectType: ON_TYPE, record: ON_RECORD }, required: ['kind', 'objectType'], additionalProperties: false },
349
364
  {
350
365
  title: 'lifecycle_transition',
351
366
  type: 'object',
@@ -416,9 +431,10 @@ const ACTION: JsonSchema = {
416
431
  $schema: DRAFT,
417
432
  title: 'Blueprint governed Action (blueprint/actions/<apiName>.yaml)',
418
433
  description:
419
- 'A governed write on one object type. Every reference is a name (subject.objectType, effect.authoritySourceSystem, an expected change’s objectType and property or transition, an input’s field); the service resolves them and mints every id. On the local runtime an Action that writes only the Blueprint’s records (effect create, update or delete) is deployed by `local dev`, and one whose effect reaches another system stays switched off. '
434
+ 'A governed write on its subject, and on the records it writes beside it (`records`), in one request. Every reference is a name (subject.objectType, effect.authoritySourceSystem, an expected change’s objectType and property or transition, an input’s field); the service resolves them and mints every id. On the local runtime an Action that writes only the Blueprint’s records (effect create, update or delete) is deployed by `local dev`, and one whose effect reaches another system stays switched off. '
420
435
  + 'An edit (subject.mode existing, effect update or delete) takes the subject’s primary key as a required input of the same name, uses concurrency expected_version, and so takes a required `expectedVersion` input ({ kind: number, integer: true, minimum: 0 }: a query row’s _meta.recordVersion). '
421
436
  + 'A create (subject.mode create, effect create) uses concurrency append_once, takes the primary key as a required input and never sets it in expectedChanges, and declares object_created. A delete declares object_deleted and sets no property. '
437
+ + 'An Action may also write up to 10 records beside its subject, in the same request and transaction: each is declared under `records` with a key, its objectType, a mode and the required input that carries its primary key (keyInput, sensitivity internal), and its changes name it with `record: <key>`; a change with no `record` is the subject’s. A record with mode create is one it creates (an Issue edit that logs an IssueEvent): it declares object_created, never sets its own key, and is held to the rules a created subject is. A record with mode existing is one that exists already, which it patches (property_set) or deletes (object_deleted): it also names the required input that carries the version the request was decided on (versionInput, { kind: number, integer: true, minimum: 0 }), is checked against that version at submission and when it is applied, and is held to the rules an edited subject is. All of it is applied, or none of it. '
422
438
  + 'A postcondition reads only properties its outcome sets; a submission criterion and an approval step’s condition may read the inputs and the record (observation_property). '
423
439
  + 'Each input feeds one property. `frontera blueprint plan` checks these rules before anything is written; `frontera blueprint new action <apiName> --template edit|create --object-type <Type>` writes a file that follows them.',
424
440
  type: 'object',
@@ -483,6 +499,24 @@ const ACTION: JsonSchema = {
483
499
  additionalProperties: false,
484
500
  },
485
501
  impact: { type: 'object', properties: { maxSubjects: { const: 1 } }, required: ['maxSubjects'], additionalProperties: false },
502
+ records: {
503
+ type: 'array',
504
+ minItems: 1,
505
+ maxItems: 10,
506
+ description: 'Records the Action writes beside its subject, applied together with the subject’s change or not at all.',
507
+ items: {
508
+ type: 'object',
509
+ properties: {
510
+ key: { type: 'string', pattern: '^[a-z][a-z0-9_]{0,99}$', description: 'The record’s name in this Action; its changes say `record: <key>`.' },
511
+ objectType: ref('The object type of the record', 'object-type'),
512
+ mode: { enum: ['create', 'existing'], description: 'create: a record this Action creates. existing: a record that exists already, which it patches or deletes under its own version check.' },
513
+ keyInput: { type: 'string', pattern: MEMBER_NAME, description: 'The required, internal input that carries the record’s primary key, by apiName. Each record has its own.' },
514
+ versionInput: { type: 'string', pattern: MEMBER_NAME, description: 'For mode existing only: the required input that carries the record version the request was decided on (a query row’s _meta.recordVersion), by apiName. Each record has its own.' },
515
+ },
516
+ required: ['key', 'objectType', 'mode', 'keyInput'],
517
+ additionalProperties: false,
518
+ },
519
+ },
486
520
  submissionCriteria: { type: 'array', items: CRITERION, description: 'Conditions every submission must meet, checked before it is accepted.' },
487
521
  governance: {
488
522
  type: 'object',
@@ -425,8 +425,8 @@ export async function resolveTarget(
425
425
  `error [UNRESOLVED_REFERENCE]: ${target.file} names what this runtime does not have`,
426
426
  ...describeUnresolved(unresolved).map((l) => ` ${l}`),
427
427
  ...byPlatformId.map((a) => ` ${a.path}: a governed Action named by its platform id (${a.id}), which this runtime does not have`),
428
- ...(byPlatformId.length ? [' hint: pull the workflow again with an organization key connected (`frontera local sync connect`) to name its Actions'] : []),
429
- ...(actions ? [` hint: governed Actions come with the Blueprint (pulled with an organization key) and are deployed on this runtime when it applies; ${actions === 1 ? 'this one is' : 'these are'} not in the project's blueprint/actions/`] : []),
428
+ ...(byPlatformId.length ? [' hint: sign in for the project’s workspace (`frontera local sync status` names the sign-in), then pull the workflow again to name its Actions'] : []),
429
+ ...(actions ? [` hint: governed Actions come with the Blueprint (pulled while signed in for the project’s workspace) and are deployed on this runtime when it applies; ${actions === 1 ? 'this one is' : 'these are'} not in the project's blueprint/actions/`] : []),
430
430
  ...(others ? [' hint: pull it from the workspace (`frontera local sync pull <kind/name>`) or add its file to the project'] : []),
431
431
  ].join('\n'),
432
432
  }
@@ -668,7 +668,8 @@ export function resolveBlueprint(project: LocalProject, refused: BlueprintRefusa
668
668
  const fit = actionFit(doc)
669
669
  const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
670
670
  const inputs = (Array.isArray(doc.inputs) ? doc.inputs : []) as Array<Record<string, any>>
671
- const touches = [fit.subject, ...changes.map((c) => c?.objectType), ...inputs.map((i) => i?.valueType?.objectType)].filter((t): t is string => typeof t === 'string')
671
+ const records = (Array.isArray(doc.records) ? doc.records : []) as Array<Record<string, any>>
672
+ const touches = [fit.subject, ...records.map((r) => r?.objectType), ...changes.map((c) => c?.objectType), ...inputs.map((i) => i?.valueType?.objectType)].filter((t): t is string => typeof t === 'string')
672
673
  const offType = touches.find((t) => dropped.has(t))
673
674
  const offField = changes.map((c) => `${c?.objectType ?? fit.subject}.${c?.property}`).find((f) => droppedFields.has(f))
674
675
  const refusal = refused.get(`action:${doc.apiName}`)
@@ -1,4 +1,5 @@
1
1
  import { containerState, followLogs, startContainer, stopContainer } from './local-docker'
2
+ import { withFix } from './fix-words'
2
3
  import type { LocalProject } from './local-project'
3
4
  import { waitForHttp } from './local-process'
4
5
  import { recordRuntime } from './local-runtime-history'
@@ -106,3 +107,30 @@ export function watchJobs(opts: { probe: () => Promise<boolean>; restart: () =>
106
107
  timer.unref?.()
107
108
  return () => clearInterval(timer)
108
109
  }
110
+
111
+ /** What gets governed work moving again, and where to see why it stopped. */
112
+ export const JOBS_STALL_FIX = '`frontera local restart jobs` restarts the jobs engine and keeps the service and App dev servers running; `frontera local logs jobs` says why it stopped'
113
+
114
+ /**
115
+ * Whether the jobs engine is delivering, as the running service reports it on
116
+ * `/v1/health` (`jobs`): governed Action work that has waited past the
117
+ * service's threshold to be picked up. The engine can answer on its port and
118
+ * still not reach the service (its calls in time out), so the port check
119
+ * that restarts it never sees this. Said, never acted on: restarting it on a
120
+ * stall would loop if the cause is elsewhere. Null when nothing waits, the
121
+ * service doesn't answer (other checks say so), or it is a release from
122
+ * before the report.
123
+ */
124
+ export async function runtimeJobsProblem(serviceUrl: string, doFetch: (url: string, init?: RequestInit) => Promise<Response> = fetch): Promise<string | null> {
125
+ try {
126
+ const res = await doFetch(`${serviceUrl.replace(/\/+$/, '')}/v1/health`, { signal: AbortSignal.timeout(3000) })
127
+ if (!res.ok) return null
128
+ const body = (await res.json().catch(() => null)) as { data?: { jobs?: { state?: string; thresholdSeconds?: number } } } | null
129
+ const jobs = body?.data?.jobs
130
+ if (jobs?.state !== 'stalled') return null
131
+ const minutes = Math.round((jobs.thresholdSeconds ?? 120) / 60)
132
+ return withFix(`the jobs engine isn’t delivering work: governed Action work has waited more than ${minutes} minute${minutes === 1 ? '' : 's'} for it, so writes, approvals and their follow-up stay pending`, JOBS_STALL_FIX)
133
+ } catch {
134
+ return null
135
+ }
136
+ }
@@ -143,7 +143,7 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
143
143
  // It runs here as it does in the workspace: a warning, not an adaptation.
144
144
  return { class: 'portable', text: `runs here as the workspace runs it, with a warning inherited from the workspace: ${need.reasons.join(' ')} The Blueprint’s author fixes the Action’s contract in the workspace` }
145
145
  case 'action-unnamed':
146
- return { class: 'platform-only', text: `runs ${need.count === 1 ? 'a governed Action' : `${need.count} governed Actions`} this key can’t name: add an organization key to bring ${need.count === 1 ? 'it' : 'them'}` }
146
+ return { class: 'platform-only', text: `runs ${need.count === 1 ? 'a governed Action' : `${need.count} governed Actions`} this key can’t name: sign in for the project’s workspace to bring ${need.count === 1 ? 'it' : 'them'}` }
147
147
  case 'connection':
148
148
  return caps.connections ? null : { class: 'stubbed', text: `${ctx.titles?.get(need.name) ?? need.name} isn’t connected here: a stand-in answers its tools with “not connected”` }
149
149
  case 'documentation':
@@ -6,10 +6,11 @@ import { join } from 'node:path'
6
6
  import { parseDocument } from 'yaml'
7
7
 
8
8
  import { credentialStore } from '../credential-store'
9
- import { forgetDeviceSession } from '../device-login'
9
+ import { forgetDeviceSession, readDeviceSession, type DeviceSession } from '../device-login'
10
10
  import { writeFileAtomic } from '../fs-atomic'
11
11
  import { configDir } from '../paths'
12
12
  import { deleteProfile, fingerprint, getProfile, putProfile } from '../profiles'
13
+ import { syncClient, type SyncClient } from './local-sync-client'
13
14
  import { PROJECT_FILES, type LocalProject } from './local-project'
14
15
 
15
16
  /**
@@ -186,6 +187,59 @@ export function targetProfileName(project: LocalProject, link: SyncLink): string
186
187
  /** Where a recorded step acts, `<host>/<workspace>`: a step whose target is not the folder's link is stale. */
187
188
  export const targetOf = (link: SyncLink) => `${new URL(link.platform).host}/${link.workspace}`
188
189
 
190
+ /**
191
+ * The person signed in for the project's target (`frontera auth login
192
+ * --browser --profile <target>`), when the sign-in is for the linked
193
+ * platform and still lasts; null otherwise. Only the target profile's own
194
+ * sign-in counts: one kept for another profile is never borrowed.
195
+ */
196
+ export function targetSession(project: LocalProject, link: SyncLink): Promise<DeviceSession | null> {
197
+ return readDeviceSession(credentialStore(), targetProfileName(project, link), link.platform)
198
+ }
199
+
200
+ /**
201
+ * The project's reader at organization grain (`client`): the person signed
202
+ * in for the target, in the linked workspace's organization, when they may
203
+ * read there (a Blueprint Steward, or an owner or admin); else the
204
+ * organization key stored; null with neither. `notSteward` names the person
205
+ * signed in when they may not and no key reads in their place.
206
+ */
207
+ export async function organizationReader(project: LocalProject, link: SyncLink): Promise<{ client: SyncClient | null; notSteward: string | null }> {
208
+ const key = getSyncKey(project, 'organization')
209
+ const byKey = key ? syncClient(link.platform, key) : null
210
+ const person = await targetSession(project, link)
211
+ const orgId = person ? await targetOrganization(project, link) : null
212
+ if (!person || !orgId) return { client: byKey, notSteward: null }
213
+ const client = syncClient(link.platform, person.token, { 'x-org-id': orgId })
214
+ if (await readsOrganization(client, person.token, orgId)) return { client, notSteward: null }
215
+ return { client: byKey, notSteward: byKey ? null : (person.email ?? 'the person') }
216
+ }
217
+
218
+ const organizationReads = new Map<string, Promise<boolean>>()
219
+
220
+ /** Whether the person may read the organization's Blueprint, asked once per process: a refusal there is a refusal of every organization read. */
221
+ function readsOrganization(client: SyncClient, token: string, orgId: string): Promise<boolean> {
222
+ const asked = [client.origin, orgId, token].join('\n')
223
+ if (!organizationReads.has(asked)) {
224
+ organizationReads.set(asked, client.find('/v1/blueprint/lifecycle').then(() => true, (err: { code?: string }) => err.code !== 'FORBIDDEN' && err.code !== 'UNAUTHORIZED'))
225
+ }
226
+ return organizationReads.get(asked)!
227
+ }
228
+
229
+ /** The linked workspace's organization, as the target profile records it: a profile recorded before it knew one is recorded again. */
230
+ async function targetOrganization(project: LocalProject, link: SyncLink): Promise<string | null> {
231
+ const name = targetProfileName(project, link)
232
+ if (!getProfile(name)?.orgId) await registerTargetProfile(project, link).catch(() => null)
233
+ return getProfile(name)?.orgId ?? null
234
+ }
235
+
236
+ /** The linked workspace's organization as the platform answers the workspace key; null when it does not. */
237
+ async function workspaceOrganization(project: LocalProject, link: SyncLink): Promise<string | null> {
238
+ const key = getSyncKey(project, 'workspace')
239
+ const me = key ? await syncClient(link.platform, key).get<{ orgId?: string | null }>('/v1/whoami').catch(() => null) : null
240
+ return me?.orgId ?? null
241
+ }
242
+
189
243
  /** The fingerprints of the keys stored for the project now. */
190
244
  export function projectKeyFingerprints(project: LocalProject): string[] {
191
245
  return (['organization', 'workspace'] as const).flatMap((role) => {
@@ -202,9 +256,11 @@ export function projectKeyFingerprints(project: LocalProject): string[] {
202
256
  * or before `connect` replaced them (`previous`), and then takes the current
203
257
  * key; one holding another key is left alone. The key is stored as every
204
258
  * profile's is, so a machine without a secure store refuses it unless its
205
- * person chose the 0600 file.
259
+ * person chose the 0600 file. `orgId` is the workspace's organization,
260
+ * which `connect` knows even with no organization key; without it, the one
261
+ * recorded, else the platform's answer to the workspace key.
206
262
  */
207
- export async function registerTargetProfile(project: LocalProject, link: SyncLink, previous: readonly string[] = []): Promise<string | null> {
263
+ export async function registerTargetProfile(project: LocalProject, link: SyncLink, previous: readonly string[] = [], orgId: string | null = null): Promise<string | null> {
208
264
  const organizationKey = getSyncKey(project, 'organization')
209
265
  const key = organizationKey ?? getSyncKey(project, 'workspace')
210
266
  if (!key) return null
@@ -219,7 +275,9 @@ export async function registerTargetProfile(project: LocalProject, link: SyncLin
219
275
  apiUrl: link.platform,
220
276
  credentialKind: organizationKey ? 'organization' : 'workspace',
221
277
  workspaceId: organizationKey ? null : link.workspace,
222
- orgId: link.organization ?? null,
278
+ // The workspace's organization as the platform reports it, so the person signed in on the profile addresses it
279
+ // when they belong to several. Never frontera.yaml's: a file in the repository does not choose it.
280
+ orgId: orgId ?? existing?.orgId ?? (await workspaceOrganization(project, link)),
223
281
  fingerprint: fingerprint(key),
224
282
  createdAt: existing?.createdAt ?? now,
225
283
  lastVerifiedAt: now,
@@ -21,7 +21,12 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
21
21
  * every element of all three versions). Such a list merges element by
22
22
  * element, matched by that key, with the object rules above. Its order is
23
23
  * ours, as the folder has it, unless only the workspace reordered the
24
- * elements all three share; what the other side added comes after.
24
+ * elements all three share; what the other side added comes after. A list
25
+ * that is a set (`SET_LISTS`: an object type's `backing.editable`) is no
26
+ * conflict where a side only reordered it: the other side's elements are
27
+ * taken, in our order when both hold the same. Any other list keeps its
28
+ * order's meaning (an evaluation's conversation turns, a dataset's key
29
+ * columns).
25
30
  * - **Multi-line strings** inside JSON or YAML (an Agent's prompts, a
26
31
  * procedure's instructions) and **every other file** (Markdown, code) merge
27
32
  * line by line, diff3-style: edits to different lines merge; the same lines
@@ -125,6 +130,12 @@ class Merger {
125
130
 
126
131
  array(path: string, b: unknown[] | null, o: unknown[], t: unknown[]): unknown {
127
132
  const key = stableKey([...(b ? [b] : []), o, t])
133
+ // A set a side only reordered (the platform lists it its own way) changed nothing on that side: the other side's
134
+ // set, and ours, as the folder has it, when both hold the same elements.
135
+ if (!key && SET_LISTS.has(path)) {
136
+ if (sameElements(o, t) || (b && sameElements(b, t))) return o
137
+ if (b && sameElements(b, o)) return t
138
+ }
128
139
  if (!key) {
129
140
  const both = union(o, t)
130
141
  const choice = this.conflict({ path, base: b ?? ABSENT, ours: o, theirs: t, both })
@@ -172,6 +183,21 @@ function union(ours: unknown[], theirs: unknown[]): unknown[] {
172
183
  return out
173
184
  }
174
185
 
186
+ /** Lists whose order means nothing, by field: an object type's `backing.editable`, which the service keeps by property id. */
187
+ const SET_LISTS = new Set(['backing.editable'])
188
+
189
+ /** Two lists holding the same elements, each as many times, in any order. */
190
+ function sameElements(a: unknown[], b: unknown[]): boolean {
191
+ if (a.length !== b.length) return false
192
+ const left = [...b]
193
+ for (const x of a) {
194
+ const at = left.findIndex((y) => deepEqual(x, y))
195
+ if (at < 0) return false
196
+ left.splice(at, 1)
197
+ }
198
+ return true
199
+ }
200
+
175
201
  /** A conflict's version as a reader sees it: `undefined` where it is absent. */
176
202
  function shown(v: unknown): unknown {
177
203
  return v === ABSENT ? undefined : v
@@ -327,6 +353,16 @@ function parse(format: Format, text: string): { ok: true; value: unknown } | { o
327
353
  }
328
354
  }
329
355
 
356
+ /** Whether two versions of a file say the same: the same document for JSON and YAML (key order aside), the same text otherwise. */
357
+ export function sameContent(path: string, a: string, b: string): boolean {
358
+ if (a === b) return true
359
+ const format = formatOf(path)
360
+ if (format === 'text') return false
361
+ const x = parse(format, a)
362
+ const y = parse(format, b)
363
+ return x.ok && y.ok && deepEqual(x.value, y.value)
364
+ }
365
+
330
366
  /** A document as a pull writes it: two-space JSON with a final newline, YAML without wrapping. */
331
367
  function render(format: Format, value: unknown): string {
332
368
  return format === 'json' ? `${JSON.stringify(value, null, 2)}\n` : stringifyYaml(value, { lineWidth: 0 })