@frontera-sdk/cli 1.52.39 → 1.52.41
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { LocalCredentials } from './local-state'
|
|
2
|
+
import { rows } from './local-sync-remote'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Knowledge ingestion on the local runtime, around a database snapshot.
|
|
6
|
+
*
|
|
7
|
+
* A document is read by a background job: its row goes `pending`, then
|
|
8
|
+
* `processing`, then `ready` with its chunks (rows in the same database). A
|
|
9
|
+
* snapshot taken while a job runs holds the row as `processing` with no
|
|
10
|
+
* chunks, and the job's work lands in the database the restore drops: once
|
|
11
|
+
* that snapshot is restored, the row says `processing` forever and nothing
|
|
12
|
+
* is reading it.
|
|
13
|
+
*
|
|
14
|
+
* So `local check` waits for ingestion to settle before it keeps the
|
|
15
|
+
* database as applied and before its tests (`waitForIngestion`), and after a
|
|
16
|
+
* restore it queues every document the snapshot holds unsettled again
|
|
17
|
+
* (`requeueSources`: the platform's retry, which replaces a document's
|
|
18
|
+
* chunks, so a second read never duplicates them). `local status` and
|
|
19
|
+
* `local check` name a document still stuck (`stuckSources`), with the
|
|
20
|
+
* command that re-queues it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface IngestSource {
|
|
24
|
+
base: string
|
|
25
|
+
baseId: string
|
|
26
|
+
id: string
|
|
27
|
+
fileName: string
|
|
28
|
+
status: string
|
|
29
|
+
updatedAt?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The two calls these need, against the runtime as its workspace key. */
|
|
33
|
+
export interface IngestApi {
|
|
34
|
+
get: (path: string) => Promise<unknown>
|
|
35
|
+
post: (path: string, body: unknown) => Promise<unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A row in one of these is being read, or waits for its job. */
|
|
39
|
+
export const UNSETTLED = new Set(['pending', 'processing'])
|
|
40
|
+
|
|
41
|
+
/** A document unsettled for longer than this has no job behind it: ingestion of a large scan takes minutes, not this. */
|
|
42
|
+
export const STUCK_AFTER_MS = 30 * 60_000
|
|
43
|
+
|
|
44
|
+
/** The runtime's API as the workspace key; null without one. */
|
|
45
|
+
export function localIngestApi(serviceUrl: string, creds: Pick<LocalCredentials, 'workspaceKey'>): IngestApi | null {
|
|
46
|
+
const key = creds.workspaceKey
|
|
47
|
+
if (!key) return null
|
|
48
|
+
const call = async (method: string, path: string, body?: unknown) => {
|
|
49
|
+
const res = await fetch(new URL(path, serviceUrl), {
|
|
50
|
+
method,
|
|
51
|
+
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
52
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
53
|
+
signal: AbortSignal.timeout(10_000),
|
|
54
|
+
})
|
|
55
|
+
const text = await res.text()
|
|
56
|
+
if (!res.ok) throw new Error(`${res.status} ${text.slice(0, 200)}`)
|
|
57
|
+
return text ? (JSON.parse(text) as unknown) : null
|
|
58
|
+
}
|
|
59
|
+
return { get: (path) => call('GET', path), post: (path, body) => call('POST', path, body) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every document in every knowledge base of the workspace; null when the runtime did not answer. */
|
|
63
|
+
export async function listIngestSources(api: IngestApi, workspaceId: string): Promise<IngestSource[] | null> {
|
|
64
|
+
try {
|
|
65
|
+
const bases = rows<{ id?: string; name?: string }>(await api.get(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`))
|
|
66
|
+
const out: IngestSource[] = []
|
|
67
|
+
for (const base of bases) {
|
|
68
|
+
if (!base.id) continue
|
|
69
|
+
const listed = rows<{ id?: string; fileName?: string; status?: string; updatedAt?: string }>(await api.get(`/v1/workspace-knowledge/${encodeURIComponent(base.id)}/sources`))
|
|
70
|
+
for (const s of listed) {
|
|
71
|
+
if (!s.id) continue
|
|
72
|
+
out.push({ base: base.name ?? base.id, baseId: base.id, id: s.id, fileName: s.fileName ?? s.id, status: String(s.status ?? ''), ...(s.updatedAt ? { updatedAt: s.updatedAt } : {}) })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The documents unsettled with no job behind them: last changed before the
|
|
83
|
+
* database was restored (the row came from the snapshot; its job ran on the
|
|
84
|
+
* database the restore replaced), or not changed for `STUCK_AFTER_MS`.
|
|
85
|
+
*/
|
|
86
|
+
export function stuckSources(sources: IngestSource[], opts: { restoredAt?: string | null; now?: number; staleMs?: number } = {}): IngestSource[] {
|
|
87
|
+
const now = opts.now ?? Date.now()
|
|
88
|
+
const staleMs = opts.staleMs ?? STUCK_AFTER_MS
|
|
89
|
+
const restored = opts.restoredAt ? Date.parse(opts.restoredAt) : NaN
|
|
90
|
+
return sources.filter((s) => {
|
|
91
|
+
if (!UNSETTLED.has(s.status)) return false
|
|
92
|
+
const at = s.updatedAt ? Date.parse(s.updatedAt) : NaN
|
|
93
|
+
if (!Number.isFinite(at)) return false
|
|
94
|
+
return (Number.isFinite(restored) && at < restored) || now - at > staleMs
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface IngestWait {
|
|
99
|
+
/** Nothing is pending or processing (or the runtime did not say). */
|
|
100
|
+
settled: boolean
|
|
101
|
+
/** What was still unsettled when the wait ended. */
|
|
102
|
+
waiting: IngestSource[]
|
|
103
|
+
/** What failed to read, said with its retry. */
|
|
104
|
+
failed: IngestSource[]
|
|
105
|
+
ms: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Wait (bounded) until no document is pending or processing. */
|
|
109
|
+
export async function waitForIngestion(
|
|
110
|
+
api: IngestApi,
|
|
111
|
+
workspaceId: string,
|
|
112
|
+
opts: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
|
|
113
|
+
): Promise<IngestWait> {
|
|
114
|
+
const now = opts.now ?? Date.now
|
|
115
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms))
|
|
116
|
+
const started = now()
|
|
117
|
+
const deadline = started + (opts.timeoutMs ?? 120_000)
|
|
118
|
+
for (;;) {
|
|
119
|
+
const sources = await listIngestSources(api, workspaceId)
|
|
120
|
+
// A runtime that does not answer is not waited on: the snapshot and the tests say what they say.
|
|
121
|
+
if (!sources) return { settled: true, waiting: [], failed: [], ms: now() - started }
|
|
122
|
+
const waiting = sources.filter((s) => UNSETTLED.has(s.status))
|
|
123
|
+
const failed = sources.filter((s) => s.status === 'failed')
|
|
124
|
+
if (!waiting.length || now() >= deadline) return { settled: !waiting.length, waiting, failed, ms: now() - started }
|
|
125
|
+
await sleep(opts.pollMs ?? 1000)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface Requeued {
|
|
130
|
+
requeued: IngestSource[]
|
|
131
|
+
refused: Array<{ source: IngestSource; reason: string }>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Each document queued to be read again: the platform's retry, which sets it pending and replaces its chunks. */
|
|
135
|
+
export async function requeueSources(api: IngestApi, sources: IngestSource[]): Promise<Requeued> {
|
|
136
|
+
const out: Requeued = { requeued: [], refused: [] }
|
|
137
|
+
for (const s of sources) {
|
|
138
|
+
try {
|
|
139
|
+
await api.post(`/v1/workspace-knowledge/${encodeURIComponent(s.baseId)}/sources/${encodeURIComponent(s.id)}/retry`, {})
|
|
140
|
+
out.requeued.push(s)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
out.refused.push({ source: s, reason: (err as Error).message })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every unsettled document after a restore: the snapshot holds it so, and its job is gone with the database it ran on. */
|
|
149
|
+
export async function requeueAfterRestore(api: IngestApi, workspaceId: string): Promise<Requeued & { listed: boolean }> {
|
|
150
|
+
const sources = await listIngestSources(api, workspaceId)
|
|
151
|
+
if (!sources) return { listed: false, requeued: [], refused: [] }
|
|
152
|
+
return { listed: true, ...(await requeueSources(api, sources.filter((s) => UNSETTLED.has(s.status)))) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A value as a shell word: quoted when it holds anything but plain characters. */
|
|
156
|
+
export const shellArg = (v: string) => (/^[\w.@/-]+$/.test(v) ? v : `"${v.replace(/(["\\$`])/g, '\\$1')}"`)
|
|
157
|
+
|
|
158
|
+
/** The command that re-queues one document. */
|
|
159
|
+
export const retryCommand = (s: IngestSource) => `frontera knowledge retry ${shellArg(s.base)} ${s.id}`
|
|
160
|
+
|
|
161
|
+
/** A stuck document, in a line with its fix. */
|
|
162
|
+
export function stuckLine(s: IngestSource): string {
|
|
163
|
+
return `Knowledge base ${s.base}: ${s.fileName} has been ${s.status} since ${s.updatedAt ?? 'an earlier run'} with no ingestion behind it, so an Agent searching the base can’t find it. Fix: \`${retryCommand(s)}\` (or \`frontera local dev --once --reapply\`)`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Documents named in a short list: `a.md, b.md` or `a.md, b.md and 3 more`. */
|
|
167
|
+
export function documentList(sources: IngestSource[], max = 3): string {
|
|
168
|
+
const names = sources.map((s) => s.fileName)
|
|
169
|
+
return names.length > max ? `${names.slice(0, max).join(', ')} and ${names.length - max} more` : names.join(', ')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface KnowledgeSettle {
|
|
173
|
+
/** What to say, each a line (a warning starts `warning:`). */
|
|
174
|
+
lines: string[]
|
|
175
|
+
/** Documents queued again, by base and file. */
|
|
176
|
+
requeued: string[]
|
|
177
|
+
/** Documents still being read when the wait ended. */
|
|
178
|
+
unsettled: string[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The knowledge as the tests and the snapshot should see it: any document
|
|
183
|
+
* stuck with no job behind it queued again, then a bounded wait until none is
|
|
184
|
+
* pending or processing. `restoredAt`: when the database was last restored
|
|
185
|
+
* (a row unsettled since before it came from the snapshot); `afterRestore`
|
|
186
|
+
* queues every unsettled document again, as a restore just made them.
|
|
187
|
+
*/
|
|
188
|
+
export async function settleKnowledge(
|
|
189
|
+
api: IngestApi,
|
|
190
|
+
workspaceId: string,
|
|
191
|
+
opts: { restoredAt?: string | null; afterRestore?: boolean; timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
|
|
192
|
+
): Promise<KnowledgeSettle> {
|
|
193
|
+
const out: KnowledgeSettle = { lines: [], requeued: [], unsettled: [] }
|
|
194
|
+
const sources = await listIngestSources(api, workspaceId)
|
|
195
|
+
if (!sources) return out
|
|
196
|
+
const now = opts.now ?? Date.now
|
|
197
|
+
const stuck = opts.afterRestore ? sources.filter((s) => UNSETTLED.has(s.status)) : stuckSources(sources, { restoredAt: opts.restoredAt, now: now() })
|
|
198
|
+
if (stuck.length) {
|
|
199
|
+
const { requeued, refused } = await requeueSources(api, stuck)
|
|
200
|
+
out.requeued = requeued.map((s) => `${s.base}/${s.fileName}`)
|
|
201
|
+
if (requeued.length) {
|
|
202
|
+
const one = requeued.length === 1
|
|
203
|
+
const why = opts.afterRestore
|
|
204
|
+
? `the restored database holds ${one ? 'it' : 'them'} unread, and ${one ? 'its' : 'their'} reading went with the database it replaced`
|
|
205
|
+
: `${one ? 'it was' : 'they were'} stuck with no ingestion behind ${one ? 'it' : 'them'}`
|
|
206
|
+
out.lines.push(`knowledge: queued ${documentList(requeued)} to be read again: ${why}`)
|
|
207
|
+
}
|
|
208
|
+
for (const r of refused) out.lines.push(`warning: ${stuckLine(r.source)} (re-queuing it was refused: ${r.reason})`)
|
|
209
|
+
}
|
|
210
|
+
if (!sources.some((s) => UNSETTLED.has(s.status)) && !stuck.length) return out
|
|
211
|
+
const waited = await waitForIngestion(api, workspaceId, { timeoutMs: opts.timeoutMs, pollMs: opts.pollMs, sleep: opts.sleep, now })
|
|
212
|
+
out.unsettled = waited.waiting.map((s) => `${s.base}/${s.fileName}`)
|
|
213
|
+
if (waited.settled) out.lines.push(`knowledge: every document is read (waited ${Math.round(waited.ms / 100) / 10} s)`)
|
|
214
|
+
else out.lines.push(`warning: knowledge: ${documentList(waited.waiting)} ${waited.waiting.length === 1 ? 'is' : 'are'} still being read after ${Math.round(waited.ms / 1000)} s, so an Agent searching ${waited.waiting.length === 1 ? 'its base' : 'their bases'} may not find ${waited.waiting.length === 1 ? 'it' : 'them'} yet; \`frontera knowledge sources <base>\` shows each`)
|
|
215
|
+
for (const f of waited.failed.filter((s) => stuck.some((x) => x.id === s.id))) out.lines.push(`warning: Knowledge base ${f.base}: ${f.fileName} failed to read again: \`frontera knowledge sources ${shellArg(f.base)}\` shows why`)
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The documents stuck on a running runtime, each as `local status` and the status tool list it; null when it did not answer. */
|
|
220
|
+
export async function stuckKnowledge(serviceUrl: string, creds: Pick<LocalCredentials, 'workspaceKey' | 'workspaceId'>, restoredAt?: string | null) {
|
|
221
|
+
const api = localIngestApi(serviceUrl, creds)
|
|
222
|
+
const sources = api ? await listIngestSources(api, creds.workspaceId) : null
|
|
223
|
+
if (!sources) return null
|
|
224
|
+
return stuckSources(sources, { restoredAt }).map((k) => ({ base: k.base, file: k.fileName, sourceId: k.id, status: k.status, since: k.updatedAt ?? null, fix: retryCommand(k), problem: stuckLine(k) }))
|
|
225
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { KNOWLEDGE_DOCUMENT } from './local-dev-references'
|
|
6
|
+
import { documentPath, documentPaths, MAX_DOCUMENT_BYTES, readFolders } from './local-knowledge-documents'
|
|
7
|
+
import { QUALITY_CHECKS_FILE } from './local-quality-checks'
|
|
8
|
+
import { rows } from './local-sync-remote'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Studio's knowledge base page saves a document into the project, not onto
|
|
12
|
+
* the runtime alone: the file lands in `knowledge/<folder>/` beside its
|
|
13
|
+
* `knowledge.json`, and `local dev` uploads it as it uploads a document added
|
|
14
|
+
* by hand (a running watcher at once, or the next `local dev`). A document
|
|
15
|
+
* added only on the runtime would be lost on the next reset or restore and
|
|
16
|
+
* never reach the project's files.
|
|
17
|
+
*
|
|
18
|
+
* What is refused, so a request cannot write anywhere else or anything the
|
|
19
|
+
* apply would not ingest:
|
|
20
|
+
* - a name that is a path (a separator, `..`, a leading dot) or a control character;
|
|
21
|
+
* - a kind `local dev` does not upload (`KNOWLEDGE_DOCUMENT`);
|
|
22
|
+
* - an empty file, or one larger than the platform takes (`MAX_DOCUMENT_BYTES`);
|
|
23
|
+
* - a name already in the folder, unless the request says to replace it;
|
|
24
|
+
* - a folder or file that is a symbolic link, or resolves outside `knowledge/`.
|
|
25
|
+
*
|
|
26
|
+
* Removing a document takes its file out of the folder. The apply only adds
|
|
27
|
+
* what the runtime lacks, so the server takes the runtime's copy off first
|
|
28
|
+
* (`removeRuntimeCopies`), as it does before a replaced file is written, so
|
|
29
|
+
* the next apply uploads the new content.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The extensions `local dev` uploads into a knowledge base, as `KNOWLEDGE_DOCUMENT` matches them. */
|
|
33
|
+
export const KNOWLEDGE_UPLOAD_EXTENSIONS = ['.md', '.markdown', '.txt', '.pdf', '.docx', '.html'] as const
|
|
34
|
+
export const KNOWLEDGE_UPLOAD_KINDS = 'Markdown, text, PDF, Word or HTML'
|
|
35
|
+
export const MAX_KNOWLEDGE_UPLOAD_BYTES = MAX_DOCUMENT_BYTES
|
|
36
|
+
|
|
37
|
+
export class KnowledgeDocumentError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
message: string,
|
|
40
|
+
readonly status: number,
|
|
41
|
+
readonly extra: Record<string, unknown> = {},
|
|
42
|
+
) {
|
|
43
|
+
super(message)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FOLDER = /^[A-Za-z0-9][\w .-]{0,127}$/
|
|
48
|
+
|
|
49
|
+
/** The project folder (`knowledge/<folder>/`) whose knowledge.json names the base `baseName`; its folder name when it gives none. */
|
|
50
|
+
export function knowledgeFolderOf(root: string, baseName: string): string | null {
|
|
51
|
+
const top = join(root, 'knowledge')
|
|
52
|
+
let names: string[]
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(top).sort()
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const wanted = baseName.trim()
|
|
59
|
+
for (const folder of names) {
|
|
60
|
+
if (!FOLDER.test(folder)) continue
|
|
61
|
+
try {
|
|
62
|
+
if (!lstatSync(join(top, folder)).isDirectory()) continue
|
|
63
|
+
const doc = JSON.parse(readFileSync(join(top, folder, 'knowledge.json'), 'utf8')) as { name?: unknown }
|
|
64
|
+
const name = typeof doc.name === 'string' && doc.name.trim() ? doc.name.trim() : folder
|
|
65
|
+
if (name === wanted) return folder
|
|
66
|
+
} catch {
|
|
67
|
+
// No knowledge.json, or not JSON: not a knowledge base `local dev` applies.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The knowledge base's folder, checked to be a real directory inside the project's `knowledge/`. */
|
|
74
|
+
function folderDir(root: string, folder: string): string {
|
|
75
|
+
if (!FOLDER.test(folder)) throw new KnowledgeDocumentError(`"${folder}" is not a knowledge base folder`, 400)
|
|
76
|
+
const top = join(root, 'knowledge')
|
|
77
|
+
const dir = join(top, folder)
|
|
78
|
+
let st
|
|
79
|
+
try {
|
|
80
|
+
st = lstatSync(dir)
|
|
81
|
+
} catch {
|
|
82
|
+
throw new KnowledgeDocumentError(`knowledge/${folder}/ is not in the project`, 404)
|
|
83
|
+
}
|
|
84
|
+
if (!st.isDirectory()) throw new KnowledgeDocumentError(`knowledge/${folder} is not a folder`, 409)
|
|
85
|
+
if (!existsSync(join(dir, 'knowledge.json'))) throw new KnowledgeDocumentError(`knowledge/${folder}/ has no knowledge.json, so local dev does not apply its documents`, 409)
|
|
86
|
+
const real = realpathSync(dir)
|
|
87
|
+
if (!real.startsWith(realpathSync(top) + sep)) throw new KnowledgeDocumentError(`knowledge/${folder}/ is outside the project's knowledge folder`, 400)
|
|
88
|
+
return dir
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A document's name as the upload gives it, checked: a plain file name of a kind `local dev` uploads. */
|
|
92
|
+
export function documentName(fileName: string): string {
|
|
93
|
+
const name = fileName.normalize('NFC').trim()
|
|
94
|
+
if (!name) throw new KnowledgeDocumentError('the document has no file name', 400)
|
|
95
|
+
if (name.length > 200) throw new KnowledgeDocumentError('the file name is longer than 200 characters', 400)
|
|
96
|
+
if (/[/\\]/.test(name) || name === '.' || name === '..' || name.startsWith('.')) {
|
|
97
|
+
throw new KnowledgeDocumentError(`"${name}" is not a file name: give the document's own name, with no folder and no leading dot`, 400)
|
|
98
|
+
}
|
|
99
|
+
if (/[\x00-\x1f\x7f]/.test(name)) throw new KnowledgeDocumentError('the file name has a control character in it', 400)
|
|
100
|
+
if (name === 'knowledge.json' || name === QUALITY_CHECKS_FILE || !KNOWLEDGE_DOCUMENT.test(name)) {
|
|
101
|
+
throw new KnowledgeDocumentError(`${name} is not a kind local dev uploads into a knowledge base: use ${KNOWLEDGE_UPLOAD_KINDS} (${KNOWLEDGE_UPLOAD_EXTENSIONS.join(', ')})`, 415)
|
|
102
|
+
}
|
|
103
|
+
return name
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A document's file inside the folder: `rel` is its path under the knowledge base's folder (`Policies/terms.pdf`). */
|
|
107
|
+
function documentFile(dir: string, rel: string): string {
|
|
108
|
+
const segments = rel.split('/')
|
|
109
|
+
if (segments.length > 16 || segments.some((s) => !s || s === '.' || s === '..' || s.startsWith('.') || s.includes('\\') || s.includes('\0'))) {
|
|
110
|
+
throw new KnowledgeDocumentError(`"${rel}" is not a document path in the knowledge base`, 400)
|
|
111
|
+
}
|
|
112
|
+
documentName(segments[segments.length - 1]!)
|
|
113
|
+
const file = resolve(dir, ...segments)
|
|
114
|
+
if (!file.startsWith(dir + sep)) throw new KnowledgeDocumentError(`"${rel}" is outside the knowledge base's folder`, 400)
|
|
115
|
+
return file
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A document's name and size, checked before anything is written or taken off the runtime; returns its name. */
|
|
119
|
+
export function checkUpload(fileName: string, bytes: number): string {
|
|
120
|
+
const name = documentName(fileName)
|
|
121
|
+
if (bytes === 0) throw new KnowledgeDocumentError(`${name} is empty`, 400)
|
|
122
|
+
if (bytes > MAX_KNOWLEDGE_UPLOAD_BYTES) {
|
|
123
|
+
throw new KnowledgeDocumentError(`${name} is larger than ${MAX_KNOWLEDGE_UPLOAD_BYTES / 1024 / 1024} MB, the most a knowledge base takes`, 413)
|
|
124
|
+
}
|
|
125
|
+
return name
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a document of that name is in the folder already (a replace takes the runtime's copy off first). */
|
|
129
|
+
export function documentInFolder(root: string, folder: string, fileName: string): boolean {
|
|
130
|
+
const file = join(folderDir(root, folder), documentName(fileName))
|
|
131
|
+
try {
|
|
132
|
+
lstatSync(file)
|
|
133
|
+
return true
|
|
134
|
+
} catch {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write a document into `knowledge/<folder>/`. A name already there is
|
|
141
|
+
* refused (409, `exists: true`) unless `replace`; the file appears whole, by
|
|
142
|
+
* rename from a hidden temporary file, so the watcher never uploads half of it.
|
|
143
|
+
*/
|
|
144
|
+
export function saveKnowledgeDocument(root: string, folder: string, fileName: string, bytes: Uint8Array, opts: { replace?: boolean } = {}): { path: string; replaced: boolean } {
|
|
145
|
+
const name = checkUpload(fileName, bytes.byteLength)
|
|
146
|
+
const dir = folderDir(root, folder)
|
|
147
|
+
const file = join(dir, name)
|
|
148
|
+
const path = `knowledge/${folder}/${name}`
|
|
149
|
+
let existing = null
|
|
150
|
+
try {
|
|
151
|
+
existing = lstatSync(file)
|
|
152
|
+
} catch {
|
|
153
|
+
// Not there: a new document.
|
|
154
|
+
}
|
|
155
|
+
if (existing && !existing.isFile()) throw new KnowledgeDocumentError(`${path} is not a plain file, so it is left as it is`, 409)
|
|
156
|
+
if (existing && !opts.replace) throw new KnowledgeDocumentError(`${path} is in the project already`, 409, { exists: true, path })
|
|
157
|
+
const tmp = join(dir, `.${randomBytes(6).toString('hex')}.upload`)
|
|
158
|
+
try {
|
|
159
|
+
writeFileSync(tmp, bytes, { flag: 'wx', mode: 0o644 })
|
|
160
|
+
renameSync(tmp, file)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
try {
|
|
163
|
+
unlinkSync(tmp)
|
|
164
|
+
} catch {
|
|
165
|
+
// Never written.
|
|
166
|
+
}
|
|
167
|
+
throw new KnowledgeDocumentError(`${path} could not be written: ${(err as Error).message}`, 500)
|
|
168
|
+
}
|
|
169
|
+
return { path, replaced: !!existing }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Take a document's file out of the folder; false when it has none there. */
|
|
173
|
+
export function removeKnowledgeDocument(root: string, folder: string, rel: string): { path: string; removed: boolean } {
|
|
174
|
+
const file = documentFile(folderDir(root, folder), rel)
|
|
175
|
+
const path = `knowledge/${folder}/${rel}`
|
|
176
|
+
let st
|
|
177
|
+
try {
|
|
178
|
+
st = lstatSync(file)
|
|
179
|
+
} catch {
|
|
180
|
+
return { path, removed: false }
|
|
181
|
+
}
|
|
182
|
+
if (!st.isFile()) throw new KnowledgeDocumentError(`${path} is not a plain file, so it is left as it is`, 409)
|
|
183
|
+
// A subfolder that is a link elsewhere is not the knowledge base's.
|
|
184
|
+
const real = realpathSync(dirname(file))
|
|
185
|
+
const top = realpathSync(join(root, 'knowledge', folder))
|
|
186
|
+
if (real !== top && !real.startsWith(top + sep)) throw new KnowledgeDocumentError(`${path} is outside the knowledge base's folder`, 400)
|
|
187
|
+
unlinkSync(file)
|
|
188
|
+
return { path, removed: true }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A call to the local service as the seeded owner, returning the parsed body (`data` envelope and all). */
|
|
192
|
+
export type ServiceCall = (method: 'GET' | 'DELETE', path: string) => Promise<unknown>
|
|
193
|
+
|
|
194
|
+
interface RuntimeSource {
|
|
195
|
+
id: string
|
|
196
|
+
/** Its path under the knowledge base's folder, as the project files it. */
|
|
197
|
+
path: string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The runtime's documents in a knowledge base, each by its path under the folder (`Policies/terms.pdf`). */
|
|
201
|
+
export async function runtimeDocuments(call: ServiceCall, knowledgeId: string): Promise<RuntimeSource[]> {
|
|
202
|
+
const listed = rows<{ id?: string; fileName?: string | null; folderId?: string | null }>(await call('GET', `/v1/workspace-knowledge/${encodeURIComponent(knowledgeId)}/sources`))
|
|
203
|
+
const folders = listed.some((s) => s.folderId) ? await readFolders({ get: (p) => call('GET', p) }, knowledgeId) : new Map<string, string>()
|
|
204
|
+
return listed.flatMap((s) => (s.id && s.fileName ? [{ id: s.id, path: documentPath(s.folderId ? (folders.get(s.folderId) ?? null) : null, s.fileName) }] : []))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Take the runtime's copies of a document off (by its path), so the next apply uploads the project's file again. */
|
|
208
|
+
export async function removeRuntimeCopies(call: ServiceCall, knowledgeId: string, rel: string): Promise<number> {
|
|
209
|
+
const copies = (await runtimeDocuments(call, knowledgeId)).filter((s) => s.path === rel)
|
|
210
|
+
for (const copy of copies) await call('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(knowledgeId)}/sources/${encodeURIComponent(copy.id)}`)
|
|
211
|
+
return copies.length
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The project's documents for a knowledge base, each with whether the runtime holds it yet. */
|
|
215
|
+
export function projectDocuments(root: string, folder: string, onRuntime: ReadonlySet<string> | null): Array<{ path: string; onRuntime: boolean | null }> {
|
|
216
|
+
return documentPaths(root, folder).map((path) => ({ path, onRuntime: onRuntime ? onRuntime.has(path) : null }))
|
|
217
|
+
}
|
|
@@ -96,7 +96,7 @@ export async function pullLessons(root: string, workspace: string, ws: SyncClien
|
|
|
96
96
|
const byAgent = new Map(rows<LessonsRow>(found).map((r) => [r.agent, r.rules]))
|
|
97
97
|
const record = readRecord(root, workspace)
|
|
98
98
|
const out: LessonsPulled = { written: [], removed: [], kept: [], rules: 0 }
|
|
99
|
-
for (const slug of [...new Set(agents)].sort()) {
|
|
99
|
+
for (const slug of [...new Set(agents)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
|
|
100
100
|
const path = join(root, lessonsPath(slug))
|
|
101
101
|
const here = existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
102
102
|
const editedHere = here !== null && record.files[slug] !== sha(here)
|
|
@@ -64,7 +64,7 @@ export function readLibrary(rows: LibraryRow[], directory: Directory): RemoteIte
|
|
|
64
64
|
release: { number: r.release.number, version: r.release.version },
|
|
65
65
|
...(r.state && r.state !== 'installed' ? { state: r.state } : {}),
|
|
66
66
|
...(agent ? { agent: `agent/${agent}` } : {}),
|
|
67
|
-
...(made.length ? { skills: [...new Set(made)].sort() } : {}),
|
|
67
|
+
...(made.length ? { skills: [...new Set(made)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)) } : {}),
|
|
68
68
|
}
|
|
69
69
|
})
|
|
70
70
|
const doc: LibraryFile = { installations }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { deflateSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml } from 'yaml'
|
|
6
|
+
|
|
7
|
+
import { readCsvRows } from './local-sample-data'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Placeholder files for the stand-in Media Sets (`local-media-sets.ts`): a
|
|
11
|
+
* media-backed object type has no rows locally, because the platform's files
|
|
12
|
+
* never come down. So that its catalog rows, its links and a read of a file
|
|
13
|
+
* (`blueprint_read_media`) can be tried here, `local dev` puts a few
|
|
14
|
+
* generated files in an empty stand-in set. They are placeholders and say so:
|
|
15
|
+
* a grey PNG whose text chunk names it one, or a text file that says it is
|
|
16
|
+
* one. They are made only while the set is empty, so a file someone uploaded
|
|
17
|
+
* to the local set is never mixed with them.
|
|
18
|
+
*
|
|
19
|
+
* Their paths follow the records that point at them: a link joining the
|
|
20
|
+
* type's `path` to another type's property (`Amenity.imageUrl`) takes that
|
|
21
|
+
* property's values from the other type's sample rows, so the link resolves.
|
|
22
|
+
* Without one, `placeholders/<type>-<n>.png`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const PLACEHOLDER_COUNT = 12
|
|
26
|
+
const TEXT_EXTENSIONS = /\.(txt|md|csv|json|html?|xml|ya?ml|log)$/i
|
|
27
|
+
|
|
28
|
+
interface LinkFile {
|
|
29
|
+
from?: { objectType?: string; property?: string }
|
|
30
|
+
to?: { objectType?: string; property?: string }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readYamlDir<T>(dir: string): T[] {
|
|
34
|
+
if (!existsSync(dir)) return []
|
|
35
|
+
return readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f)).flatMap((f) => {
|
|
36
|
+
try {
|
|
37
|
+
const doc = parseYaml(readFileSync(join(dir, f), 'utf8')) as T | null
|
|
38
|
+
return doc ? [doc] : []
|
|
39
|
+
} catch {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The column a type's property is in, and the dataset it reads: its backing, else snake case. */
|
|
46
|
+
function columnOf(type: { backing?: { dataset?: string; mapping?: Record<string, unknown> } }, property: string): { dataset: string | undefined; column: string } {
|
|
47
|
+
const m = type.backing?.mapping?.[property]
|
|
48
|
+
const column = typeof m === 'string' ? m : m && typeof m === 'object' && typeof (m as { column?: unknown }).column === 'string' ? (m as { column: string }).column : property.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
|
|
49
|
+
return { dataset: type.backing?.dataset, column }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The paths the placeholders of a media-backed type take, from the records that point at its files. */
|
|
53
|
+
export function placeholderPaths(root: string, mediaType: string, max = PLACEHOLDER_COUNT): string[] {
|
|
54
|
+
const types = new Map(readYamlDir<{ apiName?: string; backing?: { dataset?: string; mapping?: Record<string, unknown> } }>(join(root, 'blueprint', 'object-types')).flatMap((t) => (t.apiName ? [[t.apiName, t] as const] : [])))
|
|
55
|
+
const media = types.get(mediaType)
|
|
56
|
+
const isPath = (property: string | undefined) => !!property && (property === 'path' || (media ? columnOf(media, property).column === 'path' : false))
|
|
57
|
+
const found = new Set<string>()
|
|
58
|
+
for (const link of readYamlDir<LinkFile>(join(root, 'blueprint', 'link-types'))) {
|
|
59
|
+
const [mine, other] = link.from?.objectType === mediaType ? [link.from, link.to] : link.to?.objectType === mediaType ? [link.to, link.from] : [null, null]
|
|
60
|
+
if (!mine || !other?.objectType || !other.property || !isPath(mine.property)) continue
|
|
61
|
+
const otherType = types.get(other.objectType)
|
|
62
|
+
if (!otherType) continue
|
|
63
|
+
const { dataset, column } = columnOf(otherType, other.property)
|
|
64
|
+
const csv = join(root, 'datasets', `${dataset ?? other.objectType.toLowerCase()}.csv`)
|
|
65
|
+
if (!existsSync(csv)) continue
|
|
66
|
+
for (const row of readCsvRows(readFileSync(csv, 'utf8'))) {
|
|
67
|
+
const value = (row[column] ?? '').trim().replace(/^\/+/, '')
|
|
68
|
+
// A media path is a path in the set: an address elsewhere (https://…) is not one of its files.
|
|
69
|
+
if (value && !/^[a-z]+:\/\//i.test(value) && !/\.\.(\/|$)/.test(value) && value.length <= 512) found.add(value)
|
|
70
|
+
if (found.size >= max) break
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (found.size) return [...found]
|
|
74
|
+
return defaultPlaceholderPaths(mediaType, max)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The paths a media-backed type's placeholders take when no record names one:
|
|
79
|
+
* `placeholders/<type>-<n>.png`. Sample data gives the records that point at
|
|
80
|
+
* the type's files these paths (`generateSyntheticData`), so their links
|
|
81
|
+
* resolve against the placeholders `local dev` uploads.
|
|
82
|
+
*/
|
|
83
|
+
export function defaultPlaceholderPaths(mediaType: string, max = PLACEHOLDER_COUNT): string[] {
|
|
84
|
+
const stem = mediaType.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
85
|
+
return Array.from({ length: max }, (_, i) => `placeholders/${stem}-${String(i + 1).padStart(2, '0')}.png`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A placeholder file for a path: a text file for a text path, else a small grey PNG; each says it is a placeholder. */
|
|
89
|
+
export function placeholderFile(path: string): { bytes: Uint8Array; mime: string; filename: string } {
|
|
90
|
+
const filename = path.split('/').pop() || 'placeholder'
|
|
91
|
+
const words = `Placeholder for ${path}, made by \`frontera local dev\` in the local runtime's stand-in Media Set: not the platform's file.`
|
|
92
|
+
if (TEXT_EXTENSIONS.test(path)) return { bytes: new TextEncoder().encode(`${words}\n`), mime: 'text/plain', filename }
|
|
93
|
+
return { bytes: placeholderPng(words), mime: 'image/png', filename: /\.png$/i.test(filename) ? filename : `${filename}.png` }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const CRC_TABLE = (() => {
|
|
97
|
+
const table = new Uint32Array(256)
|
|
98
|
+
for (let n = 0; n < 256; n++) {
|
|
99
|
+
let c = n
|
|
100
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
101
|
+
table[n] = c >>> 0
|
|
102
|
+
}
|
|
103
|
+
return table
|
|
104
|
+
})()
|
|
105
|
+
|
|
106
|
+
function crc32(bytes: Uint8Array): number {
|
|
107
|
+
let c = 0xffffffff
|
|
108
|
+
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8)
|
|
109
|
+
return (c ^ 0xffffffff) >>> 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function chunk(type: string, data: Uint8Array): Uint8Array {
|
|
113
|
+
const out = new Uint8Array(12 + data.length)
|
|
114
|
+
const view = new DataView(out.buffer)
|
|
115
|
+
view.setUint32(0, data.length)
|
|
116
|
+
out.set(new TextEncoder().encode(type), 4)
|
|
117
|
+
out.set(data, 8)
|
|
118
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A 64×48 grey PNG with a `Comment` text chunk saying it is a placeholder. */
|
|
123
|
+
export function placeholderPng(comment: string, width = 64, height = 48): Uint8Array {
|
|
124
|
+
const header = new Uint8Array(13)
|
|
125
|
+
const hv = new DataView(header.buffer)
|
|
126
|
+
hv.setUint32(0, width)
|
|
127
|
+
hv.setUint32(4, height)
|
|
128
|
+
header.set([8, 0, 0, 0, 0], 8) // 8-bit greyscale, no interlace
|
|
129
|
+
const raw = new Uint8Array((width + 1) * height)
|
|
130
|
+
for (let y = 0; y < height; y++) {
|
|
131
|
+
raw[y * (width + 1)] = 0
|
|
132
|
+
// A diagonal stripe, so it reads as a placeholder rather than a blank.
|
|
133
|
+
for (let x = 0; x < width; x++) raw[y * (width + 1) + 1 + x] = (x + y) % 16 < 8 ? 0xc8 : 0xb4
|
|
134
|
+
}
|
|
135
|
+
const text = new TextEncoder().encode(`Comment\0${comment}`)
|
|
136
|
+
const parts = [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', header), chunk('tEXt', text), chunk('IDAT', new Uint8Array(deflateSync(raw))), chunk('IEND', new Uint8Array())]
|
|
137
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
138
|
+
let at = 0
|
|
139
|
+
for (const p of parts) {
|
|
140
|
+
out.set(p, at)
|
|
141
|
+
at += p.length
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
147
|
+
type Upload = (mediaSetId: string, file: { bytes: Uint8Array; mime: string; filename: string; logicalPath: string }) => Promise<{ status: number; body: unknown }>
|
|
148
|
+
|
|
149
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as Record<string, unknown> | null
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fill each empty stand-in set with placeholders for its type: the lines
|
|
153
|
+
* `local dev` says. A set that holds any file is left alone; a refused upload
|
|
154
|
+
* is said and stops that set.
|
|
155
|
+
*/
|
|
156
|
+
export async function fillPlaceholderMedia(root: string, mediaTypes: Readonly<Record<string, { set: string }>>, call: Call, upload: Upload): Promise<string[]> {
|
|
157
|
+
const lines: string[] = []
|
|
158
|
+
const listed = await call('GET', '/v1/media/sets')
|
|
159
|
+
if (listed.status >= 400) return [` warning: the stand-in Media Sets couldn’t be read for placeholder files (${listed.status})`]
|
|
160
|
+
const ids = new Map(((dataOf(listed.body)?.mediaSets as Array<{ id?: string; apiName?: string }> | undefined) ?? []).map((s) => [s.apiName, s.id] as const))
|
|
161
|
+
const done = new Set<string>()
|
|
162
|
+
for (const [type, { set }] of Object.entries(mediaTypes)) {
|
|
163
|
+
const id = ids.get(set)
|
|
164
|
+
if (!id || done.has(set)) continue
|
|
165
|
+
done.add(set)
|
|
166
|
+
const items = await call('GET', `/v1/media/sets/${encodeURIComponent(id)}/items`)
|
|
167
|
+
if (items.status >= 400 || ((dataOf(items.body)?.items as unknown[] | undefined) ?? []).length) continue
|
|
168
|
+
const paths = placeholderPaths(root, type)
|
|
169
|
+
let made = 0
|
|
170
|
+
for (const logicalPath of paths) {
|
|
171
|
+
const answer = await upload(id, { ...placeholderFile(logicalPath), logicalPath })
|
|
172
|
+
if (answer.status >= 400) {
|
|
173
|
+
const message = (answer.body as { message?: unknown } | null)?.message
|
|
174
|
+
lines.push(` warning: ${set}: a placeholder file couldn’t be added (${answer.status}${typeof message === 'string' ? ` ${message.slice(0, 160)}` : ''}), so ${type} has no rows here`)
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
made++
|
|
178
|
+
}
|
|
179
|
+
if (made) lines.push(` ${type}: ${made} placeholder file${made === 1 ? '' : 's'} in the stand-in Media Set ${set} (generated here, not the platform’s files), so its rows and links can be tried`)
|
|
180
|
+
}
|
|
181
|
+
return lines
|
|
182
|
+
}
|