@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.
- package/package.json +4 -4
- package/src/api/governed-action-api.ts +9 -3
- package/src/blueprint/action-rules.ts +175 -9
- package/src/blueprint/diff.ts +27 -8
- package/src/blueprint/ontology-edit-plan.ts +155 -4
- package/src/blueprint/org-name-check.ts +10 -8
- package/src/blueprint/projection.ts +13 -3
- package/src/blueprint/property-intents.ts +248 -0
- package/src/blueprint/publish-plan.ts +40 -4
- package/src/blueprint/render.ts +4 -0
- package/src/blueprint/tree.ts +22 -0
- package/src/commands/action/deploy.ts +31 -24
- package/src/commands/action/retire.ts +9 -1
- package/src/commands/action/submit.ts +21 -5
- package/src/commands/auth/login.ts +2 -2
- package/src/commands/blueprint/authoring.ts +22 -4
- package/src/commands/blueprint/declarative.ts +140 -32
- package/src/commands/knowledge/index-commands.ts +1 -1
- package/src/commands/local/check.ts +6 -4
- package/src/commands/local/index-commands.ts +8 -2
- package/src/commands/local/sync-request.ts +252 -66
- package/src/commands/local/sync.ts +29 -24
- package/src/device-login.ts +6 -1
- package/src/local/local-actions.ts +13 -1
- package/src/local/local-applied.ts +1 -1
- package/src/local/local-blueprint-schemas.ts +40 -6
- package/src/local/local-dev-references.ts +4 -3
- package/src/local/local-jobs.ts +28 -0
- package/src/local/local-portability.ts +1 -1
- package/src/local/local-sync-link.ts +62 -4
- package/src/local/local-sync-merge.ts +37 -1
- package/src/local/local-sync-plan.ts +44 -6
- package/src/local/local-sync-precheck.ts +2 -1
- package/src/local/local-sync-push.ts +22 -11
- package/src/local/local-sync-remote.ts +13 -8
- package/src/local/local-synthetic-data.ts +2 -2
- package/src/main.ts +7 -2
- 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
|
-
|
|
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
|
|
114
|
+
const { client, notSteward } = await organizationReader(project, link)
|
|
113
115
|
return {
|
|
114
116
|
workspace: syncClient(link.platform, key),
|
|
115
|
-
organization:
|
|
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
|
-
|
|
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
|
|
542
|
-
const
|
|
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 ? [
|
|
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
|
? []
|
package/src/device-login.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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:
|
|
429
|
-
...(actions ? [` hint: governed Actions come with the Blueprint (pulled
|
|
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
|
|
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}`)
|
package/src/local/local-jobs.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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 })
|