@frontera-sdk/cli 1.52.28 → 1.52.29
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 +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
|
|
6
|
+
import { assetFilename } from '../skill/bundle-commands'
|
|
7
|
+
import { packDirectory } from '../../pack'
|
|
8
|
+
import { createTarGz } from '../../tar'
|
|
9
|
+
import { functionEntry } from '../../local/local-dev-sync'
|
|
10
|
+
import { adoptProposedLessons } from '../../local/local-lessons'
|
|
11
|
+
import { localProject, type LocalProject } from '../../local/local-project'
|
|
12
|
+
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
13
|
+
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
14
|
+
import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
|
|
15
|
+
import { itemTitle } from '../../local/local-sync-names'
|
|
16
|
+
import { readLock, type SyncKind } from '../../local/local-sync-plan'
|
|
17
|
+
import {
|
|
18
|
+
adoptApplied,
|
|
19
|
+
checksOf,
|
|
20
|
+
currentRequest,
|
|
21
|
+
describeOutcome,
|
|
22
|
+
functionEntryOf,
|
|
23
|
+
isOpen,
|
|
24
|
+
itemLine,
|
|
25
|
+
KIND_WORD,
|
|
26
|
+
needsOrganizationKey,
|
|
27
|
+
parseOutcome,
|
|
28
|
+
projectRefs,
|
|
29
|
+
pushBody,
|
|
30
|
+
pushCandidates,
|
|
31
|
+
pushItemLine,
|
|
32
|
+
readRequestBase,
|
|
33
|
+
readRequests,
|
|
34
|
+
recordRequest,
|
|
35
|
+
writeRequestBase,
|
|
36
|
+
type ApplyOutcome,
|
|
37
|
+
type Check,
|
|
38
|
+
type FunctionBuild,
|
|
39
|
+
type PushAsset,
|
|
40
|
+
type PushItem,
|
|
41
|
+
} from '../../local/local-sync-push'
|
|
42
|
+
import { directoryOf, readSnapshot, snapshotItems } from '../../local/local-sync-snapshot'
|
|
43
|
+
import { buildAndExtract } from '../automation/build-entry'
|
|
44
|
+
import { findAutomationProjectRoot } from '../automation/project-root'
|
|
45
|
+
import { flagBool, flagString, type CommandContext } from '../types'
|
|
46
|
+
import { fetchFor, planFor, sourceFor } from './sync'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `frontera local sync push` and `frontera local sync request`: change
|
|
50
|
+
* requests v1 from the project (design 2026-10-17 §4, §7, §11).
|
|
51
|
+
*
|
|
52
|
+
* push [ref…] -t <title> fetch, then propose what changed here as one change request
|
|
53
|
+
* (the current one is updated, and then holds what this push sent:
|
|
54
|
+
* name items to propose only those); `--preview` lists it in words
|
|
55
|
+
* request [show] the current request: its items, each against its base, and what happened
|
|
56
|
+
* request apply apply it: each step moves a live version, and a failure puts every earlier one back;
|
|
57
|
+
* then fetch, so the applied items read as up to date
|
|
58
|
+
* request close close it without applying
|
|
59
|
+
*
|
|
60
|
+
* The request's id is kept in `.frontera/sync/requests.json` (not
|
|
61
|
+
* committed). Keys come from the keychain, as for `sync fetch`; the
|
|
62
|
+
* organization key goes beside the workspace key only when Apply has
|
|
63
|
+
* Blueprint items.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const ORGANIZATION_KEY_HEADER = 'x-frontera-organization-key'
|
|
67
|
+
|
|
68
|
+
function needLink(project: LocalProject): SyncLink {
|
|
69
|
+
const link = readLink(project.root)
|
|
70
|
+
if (!link) throw new CliError('this project is not linked to a workspace', { code: 'SYNC_NOT_LINKED', hint: 'run `frontera local sync connect --platform <url>` with a workspace key' })
|
|
71
|
+
return link
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
75
|
+
|
|
76
|
+
// ── The platform ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
type RequestBody = NonNullable<RequestInit['body']>
|
|
79
|
+
|
|
80
|
+
interface Platform {
|
|
81
|
+
call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts?: { organizationKey?: string | null; timeoutMs?: number }): Promise<T>
|
|
82
|
+
/** A body sent as it is (a document's bytes, a multipart upload), not as JSON. */
|
|
83
|
+
send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts?: { timeoutMs?: number }): Promise<T>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The change-request routes with the project's workspace key. A refusal by
|
|
88
|
+
* the blocking checks keeps them (`details.checks`), so the caller can say
|
|
89
|
+
* which item each names; Apply may take minutes, so it waits longer than a
|
|
90
|
+
* read.
|
|
91
|
+
*/
|
|
92
|
+
function platform(link: SyncLink, key: string): Platform {
|
|
93
|
+
const origin = normalizeOrigin(link.platform)
|
|
94
|
+
const request = async (method: string, path: string, headers: Record<string, string>, body: RequestBody | undefined, timeoutMs: number): Promise<Response> => {
|
|
95
|
+
try {
|
|
96
|
+
return await fetch(new URL(path, origin), { method, headers: { authorization: `Bearer ${key}`, accept: 'application/json', ...headers }, body, signal: AbortSignal.timeout(timeoutMs) })
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new CliError(`${origin} did not answer: ${(err as Error).message}`, { code: 'UNAVAILABLE', hint: 'check the address and your network, then run the command again' })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
async call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts: { organizationKey?: string | null; timeoutMs?: number } = {}): Promise<T> {
|
|
103
|
+
const res = await request(method, path, {
|
|
104
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
105
|
+
...(opts.organizationKey ? { [ORGANIZATION_KEY_HEADER]: opts.organizationKey } : {}),
|
|
106
|
+
}, body === undefined ? undefined : JSON.stringify(body), opts.timeoutMs ?? 60_000)
|
|
107
|
+
return answer<T>(res)
|
|
108
|
+
},
|
|
109
|
+
async send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts: { timeoutMs?: number } = {}): Promise<T> {
|
|
110
|
+
return answer<T>(await request(method, path, contentType ? { 'content-type': contentType } : {}, body, opts.timeoutMs ?? 300_000))
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A platform answer: its data, or a refusal in words (the checks it names kept in `details`). */
|
|
116
|
+
async function answer<T>(res: Response): Promise<T> {
|
|
117
|
+
const text = await res.text().catch(() => '')
|
|
118
|
+
let parsed: Record<string, unknown> | null = null
|
|
119
|
+
try {
|
|
120
|
+
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : null
|
|
121
|
+
} catch {
|
|
122
|
+
parsed = null
|
|
123
|
+
}
|
|
124
|
+
if (res.ok) return unwrap<T>(parsed)
|
|
125
|
+
const message = String(parsed?.message ?? (typeof parsed?.error === 'string' ? parsed.error : '') ?? '').trim() || text.slice(0, 300) || `the platform answered ${res.status}`
|
|
126
|
+
const checks = checksOf(parsed?.details)
|
|
127
|
+
if (checks.length) throw new CliError(message, { code: 'SYNC_CHECKS', hint: 'fix the items the checks name, then push again', details: { checks } })
|
|
128
|
+
if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint: 'check it is a current workspace key (sk-ws-…) for this platform; `frontera local sync connect` stores a new one' })
|
|
129
|
+
if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: 'the key’s role decides what it may propose and apply; ask a workspace admin' })
|
|
130
|
+
if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: 'the change request is gone from the workspace; propose again with `frontera local sync push`' })
|
|
131
|
+
if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: 'see where it stands with `frontera local sync request`' })
|
|
132
|
+
if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint: 'see where it stands with `frontera local sync request`' })
|
|
133
|
+
throw new CliError(`the platform answered ${res.status}: ${message}`, { code: 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function platformFor(project: LocalProject, link: SyncLink): Platform {
|
|
137
|
+
const key = getSyncKey(project, 'workspace')
|
|
138
|
+
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 (keys are per machine)' })
|
|
139
|
+
return platform(link, key)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── The platform's view of a request ────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
interface ViewItem {
|
|
145
|
+
id: string
|
|
146
|
+
ref: string
|
|
147
|
+
kind: SyncKind
|
|
148
|
+
name: string
|
|
149
|
+
baseRevision: string | null
|
|
150
|
+
state: string
|
|
151
|
+
error: string | null
|
|
152
|
+
mergedRevision: string | null
|
|
153
|
+
files: Record<string, string>
|
|
154
|
+
build?: { bundleBytes: number; hasSource: boolean } | null
|
|
155
|
+
reviews?: Array<{ verdict: string; current: boolean; reviewerId: string }>
|
|
156
|
+
deleted?: boolean
|
|
157
|
+
assets?: Array<{ path: string; bytes: number; sha256: string }>
|
|
158
|
+
documents?: { upload: Array<{ fileName: string; folder?: string | null; sha256: string; bytes: number; replaces?: string | null; staged?: boolean }>; remove: Array<{ fileName: string; sourceId: string }> }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface View {
|
|
162
|
+
request: { id: string; title: string; description: string | null; state: string; authorId: string; createdAt: string; updatedAt: string; mergedAt: string | null; closedAt?: string | null }
|
|
163
|
+
items: ViewItem[]
|
|
164
|
+
events: Array<{ id: string; kind: string; actorId: string; detail: Record<string, unknown> | null; createdAt: string }>
|
|
165
|
+
outcome?: unknown
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const STATE_WORD: Record<string, string> = {
|
|
169
|
+
draft: 'Draft',
|
|
170
|
+
open: 'Proposed',
|
|
171
|
+
approved: 'Approved',
|
|
172
|
+
merging: 'Applying',
|
|
173
|
+
merged: 'Applied',
|
|
174
|
+
closed: 'Closed',
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ITEM_STATE_WORD: Record<string, string> = {
|
|
178
|
+
open: 'In review',
|
|
179
|
+
approved: 'Approved',
|
|
180
|
+
changes_requested: 'Changes requested',
|
|
181
|
+
merged: 'Applied',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function checkLines(checks: Check[], titles: Record<string, string>): string[] {
|
|
185
|
+
return checks.map((c) => ` ${titles[c.ref] ?? c.ref}: ${c.message}`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── push ────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** Each Function built as `function deploy` builds it, base64, with its project's source. */
|
|
191
|
+
export function functionBuilder(project: Pick<LocalProject, 'root'>, ctx: Pick<CommandContext, 'output'>) {
|
|
192
|
+
return async (item: PushItem): Promise<FunctionBuild> => {
|
|
193
|
+
const entry = functionEntryOf(project.root, item.name, functionEntry)
|
|
194
|
+
const { manifest, code } = await buildAndExtract(join(project.root, entry))
|
|
195
|
+
// Its project's source, as `function deploy` keeps it, so a pull can bring it back; never a folder above this
|
|
196
|
+
// project. A project without its own package.json keeps the Function's files instead, at their project paths.
|
|
197
|
+
const root = resolve(project.root)
|
|
198
|
+
const at = findAutomationProjectRoot(resolve(root, entry))
|
|
199
|
+
const inside = at !== null && (at === root || at.startsWith(`${root}/`))
|
|
200
|
+
const tgz = inside
|
|
201
|
+
? packDirectory(at!, ctx.output, `source of ${item.name}`).tgz
|
|
202
|
+
: createTarGz(Object.entries(item.files).map(([rel, text]) => ({ rel, bytes: new TextEncoder().encode(text) })))
|
|
203
|
+
const source = Buffer.from(tgz).toString('base64')
|
|
204
|
+
return { manifest, bundle: Buffer.from(code, 'utf8').toString('base64'), source }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function itemJson(item: PushItem) {
|
|
209
|
+
return {
|
|
210
|
+
ref: item.ref,
|
|
211
|
+
kind: item.kind,
|
|
212
|
+
name: item.name,
|
|
213
|
+
title: item.title,
|
|
214
|
+
change: item.change,
|
|
215
|
+
line: pushItemLine(item),
|
|
216
|
+
proposable: item.proposable,
|
|
217
|
+
blocked: item.blocked,
|
|
218
|
+
reason: item.reason,
|
|
219
|
+
files: item.diff,
|
|
220
|
+
notes: item.notes,
|
|
221
|
+
...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes, change: a.change })) } : {}),
|
|
222
|
+
...(item.documents ? { documents: item.documents } : {}),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Each asset a skill item sends, uploaded as `frontera skill push` uploads them (content-addressed); the storage key back. */
|
|
227
|
+
function assetUploader(project: Pick<LocalProject, 'root'>, api: Platform) {
|
|
228
|
+
return async (_item: PushItem, asset: PushAsset): Promise<string> => {
|
|
229
|
+
const bytes = new Uint8Array(readFileSync(join(project.root, asset.file)))
|
|
230
|
+
const form = new FormData()
|
|
231
|
+
form.set('file', new File([bytes], asset.path.split('/').pop()!, { type: asset.mime }))
|
|
232
|
+
form.set('purpose', 'skill-asset')
|
|
233
|
+
const uploaded = await api.send<{ storagePath?: string; data?: { storagePath?: string } }>('POST', '/v1/upload', form, null, { timeoutMs: 120_000 })
|
|
234
|
+
const storagePath = uploaded?.storagePath ?? uploaded?.data?.storagePath
|
|
235
|
+
if (typeof storagePath !== 'string' || !assetFilename(storagePath)?.startsWith(asset.sha256.slice(0, 16))) {
|
|
236
|
+
throw new CliError(`skills: ${asset.file} was not stored as the platform keeps skill assets`, { code: 'UNAVAILABLE', hint: 'check the file is an image, font or template the platform takes, then push again' })
|
|
237
|
+
}
|
|
238
|
+
return storagePath
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The documents each knowledge base item lists that the request does not hold yet, sent one at a time. */
|
|
243
|
+
async function stageDocuments(project: Pick<LocalProject, 'root'>, api: Platform, view: View): Promise<number> {
|
|
244
|
+
let sent = 0
|
|
245
|
+
for (const item of view.items) {
|
|
246
|
+
if (item.kind !== 'knowledge' || !item.documents) continue
|
|
247
|
+
for (const doc of item.documents.upload) {
|
|
248
|
+
if (doc.staged) continue
|
|
249
|
+
const path = documentPath(doc.folder, doc.fileName)
|
|
250
|
+
const bytes = new Uint8Array(readFileSync(join(project.root, 'knowledge', item.name, path)))
|
|
251
|
+
if (sha256Of(bytes) !== doc.sha256) throw new CliError(`knowledge/${item.name}/${path} changed while it was proposed`, { code: 'CONFLICT', hint: 'push again' })
|
|
252
|
+
await api.send('PUT', `/v1/change-requests/${view.request.id}/documents/${doc.sha256}`, bytes, 'application/octet-stream', { timeoutMs: 300_000 })
|
|
253
|
+
sent++
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return sent
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What the workspace holds of each knowledge base whose documents are brought: its documents by name. */
|
|
260
|
+
async function platformDocuments(api: Platform, names: string[], remote: Array<{ ref: string; id?: string }>): Promise<Map<string, Array<{ id: string; fileName: string }>>> {
|
|
261
|
+
const out = new Map<string, Array<{ id: string; fileName: string }>>()
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
const id = remote.find((r) => r.ref === `knowledge/${name}`)?.id
|
|
264
|
+
if (!id) {
|
|
265
|
+
out.set(name, [])
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const listed = await api.call<unknown>('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`)
|
|
269
|
+
const rows = (Array.isArray(listed) ? listed : ((listed as { sources?: unknown[]; data?: unknown[] })?.sources ?? (listed as { data?: unknown[] })?.data ?? [])) as Array<{ id?: string; fileName?: string | null; folderId?: string | null }>
|
|
270
|
+
// By path under the knowledge base's folder, as the project files them.
|
|
271
|
+
const folders = rows.some((r) => r.folderId) ? await readFolders({ get: (path) => api.call<unknown>('GET', path) }, id) : new Map<string, string>()
|
|
272
|
+
out.set(name, rows.flatMap((r) => (r.id && r.fileName ? [{ id: r.id, fileName: documentPath(r.folderId ? folders.get(r.folderId) : null, r.fileName) }] : [])))
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What still uses each item this push would delete, read with the platform's
|
|
279
|
+
* own in-use check before anything is proposed, so a deletion names its
|
|
280
|
+
* dependents first. An older platform without the read says nothing.
|
|
281
|
+
*/
|
|
282
|
+
async function deletionUsers(project: LocalProject, link: SyncLink, items: PushItem[]): Promise<Map<string, string[]>> {
|
|
283
|
+
const refs = items.filter((i) => i.change === 'deleted' && i.proposable && i.kind !== 'blueprint').map((i) => i.ref)
|
|
284
|
+
if (!refs.length) return new Map()
|
|
285
|
+
try {
|
|
286
|
+
const answer = await platformFor(project, link).call<{ items: Array<{ ref: string; usedBy: string[] }> }>('POST', '/v1/change-requests/usage', { refs })
|
|
287
|
+
return new Map((answer.items ?? []).filter((u) => u.usedBy.length).map((u) => [u.ref, u.usedBy]))
|
|
288
|
+
} catch {
|
|
289
|
+
return new Map()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
|
|
294
|
+
const ok = items.filter((i) => i.proposable)
|
|
295
|
+
const not = items.filter((i) => !i.proposable)
|
|
296
|
+
const name = link.workspaceName ?? link.workspace
|
|
297
|
+
const lines = [
|
|
298
|
+
ok.length ? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:` : `Nothing to propose to ${name}.`,
|
|
299
|
+
// Each item's notes under it.
|
|
300
|
+
...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
|
|
301
|
+
]
|
|
302
|
+
if (not.length) lines.push('', 'Not proposed:', ...not.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`))
|
|
303
|
+
if (orgNote) lines.push('', orgNote)
|
|
304
|
+
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
305
|
+
if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
|
|
306
|
+
return lines.join('\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
310
|
+
const project = localProject(ctx.cwd)
|
|
311
|
+
const link = needLink(project)
|
|
312
|
+
const named = ctx.positional.slice(1)
|
|
313
|
+
const preview = flagBool(ctx, 'preview')
|
|
314
|
+
// Fetch first, as git does before a push: an item the workspace moved past is pulled and merged first.
|
|
315
|
+
const { plan, lock, snapshot, remote } = await planFor(project, link, { fetch: 'always', repair: 'online' })
|
|
316
|
+
if (!plan || !snapshot) throw new CliError('could not fetch the workspace', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch` and look at what it says' })
|
|
317
|
+
const available = snapshot.capabilities.includes('change-requests')
|
|
318
|
+
// Knowledge bases whose documents come too: opt-in, as for a pull (`*` for every one the project has).
|
|
319
|
+
const asked = (flagString(ctx, 'documents') ?? '').split(',').map((s) => s.trim().replace(/^knowledge\//, '')).filter(Boolean)
|
|
320
|
+
const knowledgeHere = [...projectRefs(project.root).values()].filter((r) => r.kind === 'knowledge').map((r) => r.name)
|
|
321
|
+
const documentNames = asked.includes('*') ? knowledgeHere : asked
|
|
322
|
+
const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
|
|
323
|
+
if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>), or * for every one')
|
|
324
|
+
const documents = documentNames.length
|
|
325
|
+
? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
|
|
326
|
+
: undefined
|
|
327
|
+
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
|
|
328
|
+
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
329
|
+
const updating = kept && isOpen(kept.state) ? kept : null
|
|
330
|
+
const orgStored = !!getSyncKey(project, 'organization')
|
|
331
|
+
const titles = Object.fromEntries(all.map((i) => [i.ref, i.title]))
|
|
332
|
+
|
|
333
|
+
let chosen: PushItem[]
|
|
334
|
+
if (named.length) {
|
|
335
|
+
const byRef = new Map(all.map((i) => [i.ref, i]))
|
|
336
|
+
const unknown = named.filter((r) => !byRef.has(r))
|
|
337
|
+
if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, 'list what can be proposed with `frontera local sync push --preview`')
|
|
338
|
+
chosen = named.map((r) => byRef.get(r)!)
|
|
339
|
+
} else chosen = all
|
|
340
|
+
const orgNote = needsOrganizationKey(chosen) ? (orgStored ? 'Blueprint changes are applied with the organization key stored for this project.' : 'Blueprint changes need the organization key to apply: connect again with `--org-key -` before applying.') : null
|
|
341
|
+
|
|
342
|
+
if (preview) {
|
|
343
|
+
const usedBy = available ? await deletionUsers(project, link, chosen) : new Map<string, string[]>()
|
|
344
|
+
return {
|
|
345
|
+
data: {
|
|
346
|
+
link,
|
|
347
|
+
available,
|
|
348
|
+
request: updating ? { id: updating.id, title: updating.title, state: updating.state, items: updating.refs.length, refs: updating.refs } : null,
|
|
349
|
+
items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
|
|
350
|
+
counts: { proposable: chosen.filter((i) => i.proposable).length, blocked: chosen.filter((i) => !i.proposable).length },
|
|
351
|
+
organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
|
|
352
|
+
fetchedAt: snapshot.fetchedAt,
|
|
353
|
+
},
|
|
354
|
+
text: previewText(link, chosen, updating, orgNote, available, usedBy),
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!available) throw new CliError(`${link.workspaceName ?? 'this workspace'}’s platform does not take change requests yet`, { code: 'SYNC_NO_CHANGE_REQUESTS', hint: 'make the change on the platform, or ask your administrator to update it' })
|
|
359
|
+
// Named items must each be proposable; with none named, an item the workspace moved past stops the push.
|
|
360
|
+
const stopping = chosen.filter((i) => !i.proposable && (named.length > 0 || i.blocked === 'moved' || i.blocked === 'conflicts'))
|
|
361
|
+
if (stopping.length) {
|
|
362
|
+
const lines = stopping.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)
|
|
363
|
+
throw new CliError(`not proposed: ${plural(stopping.length, 'item')} cannot go yet\n${lines.join('\n')}`, {
|
|
364
|
+
code: 'SYNC_PUSH_BLOCKED',
|
|
365
|
+
hint: stopping.some((i) => i.blocked === 'moved' || i.blocked === 'conflicts') ? 'pull and merge them first (`frontera local sync pull <ref>`), or name the items to propose' : 'name only items that can be proposed; `--preview` lists them',
|
|
366
|
+
details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
const going = chosen.filter((i) => i.proposable)
|
|
370
|
+
if (!going.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
|
|
371
|
+
const title = flagString(ctx, 'title')?.trim()
|
|
372
|
+
const description = flagString(ctx, 'description')
|
|
373
|
+
const api = platformFor(project, link)
|
|
374
|
+
// The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
|
|
375
|
+
let target = updating
|
|
376
|
+
if (target) {
|
|
377
|
+
const live = await api.call<View>('GET', `/v1/change-requests/${target.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
|
|
378
|
+
if (!live || !isOpen(live.request.state)) {
|
|
379
|
+
if (live) recordRequest(project.root, link.workspace, live)
|
|
380
|
+
target = null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
|
|
384
|
+
|
|
385
|
+
const items = await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))
|
|
386
|
+
let view: View
|
|
387
|
+
try {
|
|
388
|
+
view = target
|
|
389
|
+
? await api.call<View>('PUT', `/v1/change-requests/${target.id}`, { ...(title ? { title } : {}), ...(description !== undefined ? { description: description || null } : {}), items, replace: true }, { timeoutMs: 120_000 })
|
|
390
|
+
: await api.call<View>('POST', '/v1/change-requests', { title, ...(description ? { description } : {}), items }, { timeoutMs: 120_000 })
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
|
|
393
|
+
const checks = checksOf(err.details)
|
|
394
|
+
throw new CliError(`not proposed: the workspace refused ${plural(checks.length, 'check')}\n${checkLines(checks, titles).join('\n')}`, {
|
|
395
|
+
code: 'SYNC_PUSH_REFUSED',
|
|
396
|
+
hint: checks.some((c) => c.code === 'stale_base' || c.code === 'exists' || c.code === 'gone') ? 'fetch and pull the items named, merge them, then push again' : 'fix the items named, then push again',
|
|
397
|
+
details: { checks },
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
throw err
|
|
401
|
+
}
|
|
402
|
+
// A knowledge base's documents follow the request, one at a time (each up to the platform's limit).
|
|
403
|
+
const staged = await stageDocuments(project, api, view)
|
|
404
|
+
if (staged) view = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
|
|
405
|
+
const allTitles = { ...(kept?.titles ?? {}), ...titles }
|
|
406
|
+
recordRequest(project.root, link.workspace, view, { titles: allTitles, pushed: true })
|
|
407
|
+
writeRequestBase(project.root, view.request.id, going)
|
|
408
|
+
const notProposed = chosen.filter((i) => !i.proposable)
|
|
409
|
+
return {
|
|
410
|
+
data: {
|
|
411
|
+
proposed: true,
|
|
412
|
+
created: !target,
|
|
413
|
+
request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
|
|
414
|
+
pushed: going.map((i) => i.ref),
|
|
415
|
+
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
|
|
416
|
+
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
417
|
+
},
|
|
418
|
+
text: [
|
|
419
|
+
`${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
|
|
420
|
+
...going.map((i) => ` ${pushItemLine(i)}`),
|
|
421
|
+
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)] : []),
|
|
422
|
+
...(orgNote ? ['', orgNote] : []),
|
|
423
|
+
'',
|
|
424
|
+
'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
|
|
425
|
+
].join('\n'),
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── request ─────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/** The base each item was changed from: the copy kept at push, the fetched snapshot, or the platform at that revision. */
|
|
432
|
+
async function basesFor(project: LocalProject, link: SyncLink, view: View): Promise<Record<string, { files: Record<string, string>; known: boolean }>> {
|
|
433
|
+
const kept = readRequestBase(project.root, view.request.id)
|
|
434
|
+
const snapshot = readSnapshot(project.root)
|
|
435
|
+
const remote = snapshot && snapshot.workspace === link.workspace ? new Map(snapshotItems(project.root, snapshot).map((i) => [i.ref, i])) : new Map()
|
|
436
|
+
const out: Record<string, { files: Record<string, string>; known: boolean }> = {}
|
|
437
|
+
for (const item of view.items) {
|
|
438
|
+
if (!item.baseRevision) {
|
|
439
|
+
out[item.ref] = { files: {}, known: true }
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
if (kept.known) {
|
|
443
|
+
const files: Record<string, string> = {}
|
|
444
|
+
// A deletion sends no files: its base's paths were kept with the copy.
|
|
445
|
+
for (const path of item.deleted ? kept.paths(item.ref) : Object.keys(item.files)) {
|
|
446
|
+
const text = kept.read(path)
|
|
447
|
+
if (text !== null) files[path] = text
|
|
448
|
+
}
|
|
449
|
+
out[item.ref] = { files, known: true }
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
const theirs = remote.get(item.ref)
|
|
453
|
+
const groupRevision = item.kind === 'blueprint' ? snapshot?.groups.blueprint : theirs?.revision
|
|
454
|
+
if (theirs && groupRevision === item.baseRevision) {
|
|
455
|
+
out[item.ref] = { files: theirs.files, known: true }
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
out[item.ref] = { files: {}, known: false }
|
|
459
|
+
// Earlier versions the platform keeps: workflows, Functions and the Blueprint by revision.
|
|
460
|
+
if (item.kind === 'agent' || !HISTORY_KINDS.includes(item.kind) || !snapshot?.capabilities.includes('item-revision')) continue
|
|
461
|
+
try {
|
|
462
|
+
const source = sourceFor(project, link)
|
|
463
|
+
const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
|
|
464
|
+
const answer = await source.workspace.find<{ ref: string; kind: string; revision: string; content: Record<string, unknown> }>(`/v1/sync/item?ref=${encodeURIComponent(asked)}&revision=${encodeURIComponent(item.baseRevision)}`)
|
|
465
|
+
if (answer) out[item.ref] = { files: await projectAtRevision(source, item.ref, answer, Object.keys(item.files), directoryOf(snapshot)), known: true }
|
|
466
|
+
} catch {
|
|
467
|
+
// Shown without its base; the page says so.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return out
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The latest refusal by the checks, when nothing happened to the request since. */
|
|
474
|
+
function lastChecks(view: View): Check[] {
|
|
475
|
+
const events = [...view.events].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
476
|
+
const last = events.filter((e) => ['created', 'pushed', 'apply_refused', 'apply_started', 'merged', 'apply_failed', 'closed'].includes(e.kind)).pop()
|
|
477
|
+
return last?.kind === 'apply_refused' ? checksOf(last.detail) : []
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** What happened to the request, one entry per event, in words. */
|
|
481
|
+
function activityOf(view: View, titles: Record<string, string>) {
|
|
482
|
+
const name = (ref: unknown) => (typeof ref === 'string' ? titles[ref] ?? ref : 'an item')
|
|
483
|
+
const names = (refs: unknown) => (Array.isArray(refs) ? refs.map(name).join(', ') : '')
|
|
484
|
+
return [...view.events]
|
|
485
|
+
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
486
|
+
.map((e) => {
|
|
487
|
+
const d = (e.detail ?? {}) as Record<string, unknown>
|
|
488
|
+
const by = e.actorId === view.request.authorId ? 'you' : 'teammate'
|
|
489
|
+
let text: string
|
|
490
|
+
let outcome: ApplyOutcome | undefined
|
|
491
|
+
switch (e.kind) {
|
|
492
|
+
case 'created':
|
|
493
|
+
text = `Proposed ${plural(Array.isArray(d.refs) ? d.refs.length : 0, 'item')}`
|
|
494
|
+
break
|
|
495
|
+
case 'pushed': {
|
|
496
|
+
const parts = [
|
|
497
|
+
Array.isArray(d.added) && d.added.length ? `added ${names(d.added)}` : '',
|
|
498
|
+
Array.isArray(d.changed) && d.changed.length ? `updated ${names(d.changed)}` : '',
|
|
499
|
+
Array.isArray(d.removed) && d.removed.length ? `took out ${names(d.removed)}` : '',
|
|
500
|
+
].filter(Boolean)
|
|
501
|
+
text = parts.length ? `Pushed: ${parts.join('; ')}` : 'Pushed again; nothing changed'
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
case 'approvals_dismissed':
|
|
505
|
+
text = `The approval on ${name(d.ref)} was dismissed: it changed`
|
|
506
|
+
break
|
|
507
|
+
case 'reviewed':
|
|
508
|
+
text = d.implicit ? 'Approved on Apply' : d.verdict === 'request_changes' ? `Changes requested on ${name(d.ref)}` : `Approved ${name(d.ref)}`
|
|
509
|
+
break
|
|
510
|
+
case 'apply_started':
|
|
511
|
+
text = 'Apply started'
|
|
512
|
+
break
|
|
513
|
+
case 'apply_refused':
|
|
514
|
+
text = `Apply refused: ${plural(checksOf(d).length, 'check')} failed`
|
|
515
|
+
break
|
|
516
|
+
case 'apply_failed': {
|
|
517
|
+
outcome = parseOutcome({ ok: false, steps: d.steps, failure: d.failure, compensation: d.compensation })
|
|
518
|
+
const failed = outcome.steps.find((s) => s.status === 'failed')
|
|
519
|
+
text = `Apply failed${failed ? ` at “${failed.label}”` : ''}; ${outcome.compensation.length ? `${plural(outcome.compensation.length, 'change')} put back` : 'nothing had changed'}`
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
case 'merged':
|
|
523
|
+
outcome = parseOutcome({ ok: true, steps: d.steps, compensation: [] })
|
|
524
|
+
text = `Applied: ${plural(outcome.steps.length, 'step')}`
|
|
525
|
+
break
|
|
526
|
+
case 'compensated':
|
|
527
|
+
text = 'An Apply that stopped part-way was put back'
|
|
528
|
+
break
|
|
529
|
+
case 'closed':
|
|
530
|
+
text = 'Closed'
|
|
531
|
+
break
|
|
532
|
+
default:
|
|
533
|
+
text = e.kind.replace(/_/g, ' ')
|
|
534
|
+
}
|
|
535
|
+
return { kind: e.kind, at: e.createdAt, by, text, ...(outcome ? { steps: outcome.steps, compensation: outcome.compensation, failure: outcome.failure ?? null } : {}) }
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string> } | null> {
|
|
540
|
+
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
541
|
+
if (!kept) return null
|
|
542
|
+
const view = await platformFor(project, link).call<View>('GET', `/v1/change-requests/${kept.id}`)
|
|
543
|
+
recordRequest(project.root, link.workspace, view)
|
|
544
|
+
return { view, titles: kept.titles }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* An item of the request by its name today (`local-sync-names.ts`): the
|
|
549
|
+
* title kept when it was proposed, unless that is only a slug form (a
|
|
550
|
+
* request proposed before items were named by display name kept "brand"),
|
|
551
|
+
* then what its proposed files declare. The kept titles are not rewritten.
|
|
552
|
+
*/
|
|
553
|
+
function titleOf(item: ViewItem, titles: Record<string, string>): string {
|
|
554
|
+
return itemTitle(item.kind, item.name, item.files, titles[item.ref])
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function show(ctx: CommandContext) {
|
|
558
|
+
const project = localProject(ctx.cwd)
|
|
559
|
+
const link = needLink(project)
|
|
560
|
+
const current = await currentView(project, link)
|
|
561
|
+
if (!current) {
|
|
562
|
+
return { data: { request: null }, text: 'No change request yet. Propose what changed here with `frontera local sync push -t "<title>"`.' }
|
|
563
|
+
}
|
|
564
|
+
const { view, titles } = current
|
|
565
|
+
const bases = await basesFor(project, link, view)
|
|
566
|
+
const checks = lastChecks(view)
|
|
567
|
+
const items = view.items.map((item) => {
|
|
568
|
+
const base = bases[item.ref]!
|
|
569
|
+
const paths = [...new Set([...Object.keys(base.files), ...Object.keys(item.files)])].sort()
|
|
570
|
+
const files = paths.map((path) => ({ path, base: base.files[path] ?? null, local: item.files[path] ?? null })).filter((f) => f.base !== f.local || !base.known)
|
|
571
|
+
const change = item.deleted ? ('deleted' as const) : item.baseRevision ? ('changed' as const) : ('new' as const)
|
|
572
|
+
const title = titleOf(item, titles)
|
|
573
|
+
const documentsChanged = Boolean(item.documents && (item.documents.upload.length || item.documents.remove.length))
|
|
574
|
+
return {
|
|
575
|
+
ref: item.ref,
|
|
576
|
+
kind: item.kind,
|
|
577
|
+
name: item.name,
|
|
578
|
+
title,
|
|
579
|
+
change,
|
|
580
|
+
line: itemLine({ kind: item.kind, name: item.name, title, change, changedPaths: base.known ? files.map((f) => f.path) : [], assetsChanged: Boolean(item.assets?.length) && base.known && !files.length, documentsChanged }),
|
|
581
|
+
state: item.state,
|
|
582
|
+
stateLabel: ITEM_STATE_WORD[item.state] ?? item.state,
|
|
583
|
+
error: item.error,
|
|
584
|
+
checks: checks.filter((c) => c.ref === item.ref || c.itemId === item.id).map((c) => ({ code: c.code, message: c.message })),
|
|
585
|
+
baseKnown: base.known,
|
|
586
|
+
files,
|
|
587
|
+
build: item.build ? { hasSource: item.build.hasSource } : null,
|
|
588
|
+
...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes })) } : {}),
|
|
589
|
+
...(item.documents
|
|
590
|
+
? {
|
|
591
|
+
documents: {
|
|
592
|
+
upload: item.documents.upload.map((d) => ({ fileName: documentPath(d.folder, d.fileName), bytes: d.bytes, change: d.replaces ? ('changed' as const) : ('new' as const), staged: d.staged !== false })),
|
|
593
|
+
remove: item.documents.remove.map((d) => ({ fileName: d.fileName })),
|
|
594
|
+
},
|
|
595
|
+
}
|
|
596
|
+
: {}),
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
const allTitles = Object.fromEntries(items.map((i) => [i.ref, i.title]))
|
|
600
|
+
const activity = activityOf(view, allTitles)
|
|
601
|
+
const orgStored = !!getSyncKey(project, 'organization')
|
|
602
|
+
const r = view.request
|
|
603
|
+
return {
|
|
604
|
+
data: {
|
|
605
|
+
request: { id: r.id, title: r.title, description: r.description, state: r.state, stateLabel: STATE_WORD[r.state] ?? r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, mergedAt: r.mergedAt },
|
|
606
|
+
workspace: { name: link.workspaceName ?? null, organizationName: link.organizationName ?? null },
|
|
607
|
+
items,
|
|
608
|
+
activity,
|
|
609
|
+
checks: checks.filter((c) => !items.some((i) => i.ref === c.ref)).map((c) => ({ ref: c.ref, code: c.code, message: c.message })),
|
|
610
|
+
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
611
|
+
},
|
|
612
|
+
text: [
|
|
613
|
+
`${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${link.workspaceName ?? link.workspace}`,
|
|
614
|
+
...(r.description ? [r.description] : []),
|
|
615
|
+
'',
|
|
616
|
+
...items.flatMap((i) => [
|
|
617
|
+
` ${i.line} (${i.stateLabel})`,
|
|
618
|
+
...(i.error ? [` ! ${i.error}`] : []),
|
|
619
|
+
...i.checks.map((c) => ` ! ${c.message}`),
|
|
620
|
+
...(i.baseKnown ? [] : [' · its base isn’t on this machine; its files are shown whole']),
|
|
621
|
+
]),
|
|
622
|
+
'',
|
|
623
|
+
'Activity:',
|
|
624
|
+
...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
|
|
625
|
+
...(isOpen(r.state) ? ['', 'Apply it with `frontera local sync request apply`, or close it with `request close`.'] : []),
|
|
626
|
+
].join('\n'),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function apply(ctx: CommandContext) {
|
|
631
|
+
const project = localProject(ctx.cwd)
|
|
632
|
+
const link = needLink(project)
|
|
633
|
+
const current = await currentView(project, link)
|
|
634
|
+
if (!current) throw new UsageError('no change request to apply', 'propose one with `frontera local sync push -t "<title>"`')
|
|
635
|
+
const { view, titles } = current
|
|
636
|
+
if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: 'propose new changes with `frontera local sync push -t "<title>"`' })
|
|
637
|
+
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: 'propose the changes again with `frontera local sync push -t "<title>"`' })
|
|
638
|
+
const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
|
|
639
|
+
const blueprint = view.items.some((i) => i.kind === 'blueprint')
|
|
640
|
+
const organizationKey = blueprint ? getSyncKey(project, 'organization') : null
|
|
641
|
+
if (blueprint && !organizationKey) {
|
|
642
|
+
throw new CliError('this change request changes the Blueprint, and applying it needs the organization key; none is stored for this project', { code: 'SYNC_NO_ORG_KEY', hint: 'connect again with `frontera local sync connect --workspace-key - --org-key -` (both keys on stdin), then apply' })
|
|
643
|
+
}
|
|
644
|
+
const migration = flagString(ctx, 'migration')?.trim()
|
|
645
|
+
let answer: View
|
|
646
|
+
try {
|
|
647
|
+
answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, migration ? { migrationInstruction: migration } : {}, { organizationKey, timeoutMs: 15 * 60_000 })
|
|
648
|
+
} catch (err) {
|
|
649
|
+
if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
|
|
650
|
+
const checks = checksOf(err.details)
|
|
651
|
+
throw new CliError(`not applied: ${plural(checks.length, 'check')} failed\n${checkLines(checks, allTitles).join('\n')}`, {
|
|
652
|
+
code: 'SYNC_APPLY_REFUSED',
|
|
653
|
+
hint: checks.some((c) => c.code === 'stale_base' || c.code === 'gone' || c.code === 'exists')
|
|
654
|
+
? 'the workspace changed these items since they were proposed: pull and merge them (`frontera local sync pull <ref>`), push again, then apply'
|
|
655
|
+
: 'fix the items named and push again, then apply',
|
|
656
|
+
details: { checks },
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
throw err
|
|
660
|
+
}
|
|
661
|
+
const outcome = parseOutcome(answer.outcome)
|
|
662
|
+
recordRequest(project.root, link.workspace, answer, { applied: { ok: outcome.ok } })
|
|
663
|
+
if (!outcome.ok) {
|
|
664
|
+
// Fetch, so the project sees the workspace as it was put back. The request itself applies again as it is:
|
|
665
|
+
// the platform takes each put-back item's new revision as its base.
|
|
666
|
+
await fetchFor(project, link).catch(() => null)
|
|
667
|
+
throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
|
|
668
|
+
code: 'SYNC_APPLY_FAILED',
|
|
669
|
+
hint: /migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : 'once what failed is fixed, apply again (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)',
|
|
670
|
+
details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: allTitles[outcome.failure.ref] ?? outcome.failure.ref, message: outcome.failure.message } : null },
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
// The workspace now has the applied versions: fetch, and move each applied item's base to them.
|
|
674
|
+
let fetched: { changed: number; adopted: string[]; error: string | null } = { changed: 0, adopted: [], error: null }
|
|
675
|
+
try {
|
|
676
|
+
// A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
|
|
677
|
+
const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
|
|
678
|
+
const result = await fetchFor(project, link, { full: deleted.length > 0 })
|
|
679
|
+
const { adopted } = adoptApplied(project.root, link.workspace, answer.items.map((i) => i.ref), snapshotItems(project.root, result.snapshot), readLock(project.root), new Date(), { deleted })
|
|
680
|
+
// An Agent's lessons were proposed for review: not proposed again; the next pull brings what reviewers approve.
|
|
681
|
+
adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
|
|
682
|
+
fetched = { changed: result.changed.length, adopted, error: null }
|
|
683
|
+
} catch (err) {
|
|
684
|
+
fetched.error = (err as Error).message
|
|
685
|
+
}
|
|
686
|
+
// Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
|
|
687
|
+
await recordAppliedDocuments(project, link, answer).catch(() => null)
|
|
688
|
+
return {
|
|
689
|
+
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched },
|
|
690
|
+
text: [
|
|
691
|
+
`Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}.`,
|
|
692
|
+
...describeOutcome(outcome, allTitles),
|
|
693
|
+
'',
|
|
694
|
+
fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
|
|
695
|
+
].join('\n'),
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* After an Apply brought documents: each one uploaded is recorded against the
|
|
701
|
+
* workspace's new copy (the newest document of that name), and each taken off
|
|
702
|
+
* is forgotten, so the next push sees them as unchanged.
|
|
703
|
+
*/
|
|
704
|
+
async function recordAppliedDocuments(project: LocalProject, link: SyncLink, view: View): Promise<void> {
|
|
705
|
+
const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
|
|
706
|
+
if (!items.length) return
|
|
707
|
+
const snapshot = readSnapshot(project.root)
|
|
708
|
+
const remote = snapshot ? snapshotItems(project.root, snapshot) : []
|
|
709
|
+
const api = platformFor(project, link)
|
|
710
|
+
for (const item of items) {
|
|
711
|
+
const id = remote.find((r) => r.ref === item.ref)?.id
|
|
712
|
+
if (!id) continue
|
|
713
|
+
const listed = await api.call<unknown>('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`)
|
|
714
|
+
const rows = (Array.isArray(listed) ? listed : []) as Array<{ id?: string; fileName?: string | null; createdAt?: string | null; folderId?: string | null }>
|
|
715
|
+
const folders = rows.some((r) => r.folderId) ? await readFolders({ get: (path) => api.call<unknown>('GET', path) }, id) : new Map<string, string>()
|
|
716
|
+
const pathOf = (r: { fileName?: string | null; folderId?: string | null }) => documentPath(r.folderId ? folders.get(r.folderId) : null, r.fileName ?? '')
|
|
717
|
+
const entries: Record<string, PulledDocument | null> = {}
|
|
718
|
+
for (const d of item.documents!.upload) {
|
|
719
|
+
const path = documentPath(d.folder, d.fileName)
|
|
720
|
+
const newest = rows.filter((r) => r.id && pathOf(r) === path).sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')))[0]
|
|
721
|
+
if (newest?.id) entries[path] = { sourceId: newest.id, sha256: d.sha256, bytes: d.bytes, at: new Date().toISOString() }
|
|
722
|
+
}
|
|
723
|
+
// A document taken off is forgotten by the copy it was, wherever it was filed.
|
|
724
|
+
const pulled = readPulledDocuments(project.root, link.workspace).bases[item.name] ?? {}
|
|
725
|
+
for (const d of item.documents!.remove) {
|
|
726
|
+
const at = Object.entries(pulled).find(([, was]) => was.sourceId === d.sourceId)?.[0]
|
|
727
|
+
entries[at ?? d.fileName] = null
|
|
728
|
+
}
|
|
729
|
+
recordPulledDocuments(project.root, link.workspace, item.name, entries)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function close(ctx: CommandContext) {
|
|
734
|
+
const project = localProject(ctx.cwd)
|
|
735
|
+
const link = needLink(project)
|
|
736
|
+
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
737
|
+
if (!kept || !isOpen(kept.state)) throw new UsageError('no open change request to close', 'see where it stands with `frontera local sync request`')
|
|
738
|
+
const view = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
|
|
739
|
+
recordRequest(project.root, link.workspace, view)
|
|
740
|
+
return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”. The project’s files are unchanged; the next push opens a new change request.` }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const REQUEST_ACTIONS: Record<string, (ctx: CommandContext) => Promise<{ data: unknown; text: string }>> = { show, apply, close }
|
|
744
|
+
|
|
745
|
+
export async function syncRequest(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
746
|
+
const action = ctx.positional[1] ?? 'show'
|
|
747
|
+
const run = REQUEST_ACTIONS[action]
|
|
748
|
+
if (!run) throw new UsageError(`unknown request action \`${action}\``, 'use show, apply or close')
|
|
749
|
+
return run(ctx)
|
|
750
|
+
}
|