@frontera-sdk/cli 1.52.39 → 1.52.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -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
+ }
@@ -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
+ }