@volter/twin-fireworks 0.1.0
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/LICENSE +202 -0
- package/README.md +184 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/fireworks-budget.d.ts +54 -0
- package/dist/src/fireworks-budget.js +146 -0
- package/dist/src/fireworks-capabilities.d.ts +4 -0
- package/dist/src/fireworks-capabilities.js +1205 -0
- package/dist/src/fireworks-conformance.d.ts +14 -0
- package/dist/src/fireworks-conformance.js +514 -0
- package/dist/src/fireworks-connector.d.ts +168 -0
- package/dist/src/fireworks-connector.js +641 -0
- package/dist/src/fireworks-models.d.ts +11 -0
- package/dist/src/fireworks-models.js +53 -0
- package/dist/src/fireworks-scenario.d.ts +55 -0
- package/dist/src/fireworks-scenario.js +171 -0
- package/dist/src/fireworks-server.d.ts +16 -0
- package/dist/src/fireworks-server.js +144 -0
- package/dist/src/fireworks-stub.d.ts +26 -0
- package/dist/src/fireworks-stub.js +78 -0
- package/dist/src/fireworks-twin.d.ts +51 -0
- package/dist/src/fireworks-twin.js +1426 -0
- package/dist/src/fireworks-types.d.ts +212 -0
- package/dist/src/fireworks-types.js +4 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +105 -0
- package/package.json +52 -0
- package/src/cli.ts +27 -0
- package/src/fireworks-budget.ts +172 -0
- package/src/fireworks-capabilities.ts +1229 -0
- package/src/fireworks-conformance.ts +542 -0
- package/src/fireworks-connector.ts +700 -0
- package/src/fireworks-models.ts +63 -0
- package/src/fireworks-scenario.ts +191 -0
- package/src/fireworks-server.ts +153 -0
- package/src/fireworks-stub.ts +83 -0
- package/src/fireworks-twin.ts +1427 -0
- package/src/fireworks-types.ts +165 -0
- package/src/index.ts +134 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// Fireworks CONNECTOR — the live-vendor pull/push path that gives the Fireworks twin the full
|
|
2
|
+
// "git for SaaS" lifecycle, over an INJECTED executor (the auth boundary; the kernel + this pack
|
|
3
|
+
// hold NO key and import NO SDK at runtime).
|
|
4
|
+
//
|
|
5
|
+
// PULL (real → twin): enumerate the CONTROL plane (the stateful surface — deployments, datasets,
|
|
6
|
+
// batch-inference and supervised-fine-tuning jobs, users, models, secrets)
|
|
7
|
+
// through the Gateway REST list endpoints and fold into the event log via
|
|
8
|
+
// the kernel's `observeResources` (protocol 2 — one batch, one instant,
|
|
9
|
+
// shadow-diff dedup, so a re-pull of identical state appends ZERO deltas).
|
|
10
|
+
// The INFERENCE plane stores nothing (chat/completions, embeddings, rerank
|
|
11
|
+
// are generative), so there is nothing there to pull.
|
|
12
|
+
// PUSH (twin → real): replay each pending local write against the real Gateway REST surface and
|
|
13
|
+
// confirm it. Inference operations are NOT pushes — replaying a completion
|
|
14
|
+
// would spend real money and store nothing — so they are refused by name.
|
|
15
|
+
//
|
|
16
|
+
// The vendor I/O is an INJECTED executor (`FireworksExecute`): a fake in tests, `liveFireworksExecute`
|
|
17
|
+
// in prod. Same code path either way — fully exercisable offline.
|
|
18
|
+
import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources, projectResources } from '@volter/world-core';
|
|
19
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
20
|
+
import { FireworksBudget, FireworksBudgetError, fireworksBudgetPath, fireworksCallWeight, type FireworksBudgetOptions } from './fireworks-budget.ts';
|
|
21
|
+
|
|
22
|
+
const SERVICE = 'fireworks';
|
|
23
|
+
const DEFAULT_OCCURRED_AT = '1970-01-01T00:00:00.000Z';
|
|
24
|
+
/** The account every Gateway REST path is addressed under when no caller (or subject namespace)
|
|
25
|
+
* supplies one. The vendor keys every control-plane path by account id; the twin models one
|
|
26
|
+
* default account, and the perform path derives the account from the SUBJECT's own namespace
|
|
27
|
+
* first, falling back to this constant only for a bare id. */
|
|
28
|
+
export const FIREWORKS_ACCOUNT_ID = 'my-account';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The injected real-Fireworks boundary. `request` issues ONE Fireworks REST call against either
|
|
32
|
+
* plane:
|
|
33
|
+
* method — 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
34
|
+
* path — e.g. '/inference/v1/chat/completions' or
|
|
35
|
+
* '/v1/accounts/{account_id}/deployments?deploymentId=my-deployment'
|
|
36
|
+
* body — JSON body for POST/PATCH (omitted otherwise)
|
|
37
|
+
* Returns the parsed JSON envelope (an OpenAI-compat completion, a gateway list envelope, or an
|
|
38
|
+
* error shape).
|
|
39
|
+
*/
|
|
40
|
+
export type FireworksExecute = (
|
|
41
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
42
|
+
path: string,
|
|
43
|
+
body?: Record<string, unknown>,
|
|
44
|
+
) => Promise<{ status: number; data: unknown }>;
|
|
45
|
+
|
|
46
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
47
|
+
export type LiveFireworksOptions = {
|
|
48
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
51
|
+
budget?: FireworksBudget;
|
|
52
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
53
|
+
budgetOptions?: FireworksBudgetOptions;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A live executor against the real Fireworks API (the user's own API key). Sends the required
|
|
58
|
+
* `Authorization: Bearer` header. Never imported by the pack's own serve path — only constructed
|
|
59
|
+
* by a caller that opts into real I/O.
|
|
60
|
+
*
|
|
61
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.fireworks.ai` request, and therefore the one
|
|
62
|
+
* place the rate budget is enforced. EVERY call is guarded: the budget is charged BEFORE the
|
|
63
|
+
* request goes out (`checkBudget`, which THROWS `FireworksBudgetError` instead of returning when
|
|
64
|
+
* the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
65
|
+
* `retry-after` / 429 becomes a persisted cooldown that makes every later call fail fast WITHOUT
|
|
66
|
+
* touching Fireworks. There is deliberately no OPTION to disable the guard, and no value a caller
|
|
67
|
+
* can pass for `budget` that yields an unguarded client. What that does NOT claim is immunity from
|
|
68
|
+
* a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an injected clock,
|
|
69
|
+
* restores the allowance, because the same seam tests need cannot be denied to a determined caller
|
|
70
|
+
* in the same process. See `fireworks-budget.ts` and the kernel header for the limits of the
|
|
71
|
+
* guarantee.
|
|
72
|
+
*/
|
|
73
|
+
export function liveFireworksExecute(
|
|
74
|
+
apiKey: string,
|
|
75
|
+
base = 'https://api.fireworks.ai',
|
|
76
|
+
opts: LiveFireworksOptions = {},
|
|
77
|
+
): FireworksExecute {
|
|
78
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
79
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
80
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
81
|
+
// must be an UNMODIFIED FireworksBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
82
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that would
|
|
83
|
+
// otherwise hand back a client with no ceiling. The default ledger is keyed by a hash of THIS
|
|
84
|
+
// key: Fireworks meters per ACCOUNT, so a cwd-scoped ledger would hand the same key a fresh
|
|
85
|
+
// allowance per checkout/worktree/CI leg.
|
|
86
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
87
|
+
? assertBudgetGuardIntact(opts.budget, FireworksBudget, 'liveFireworksExecute')
|
|
88
|
+
: new FireworksBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
89
|
+
return async (method, path, body) => {
|
|
90
|
+
// A path this pack never modelled is refused BEFORE the budget is even charged: an executor
|
|
91
|
+
// that will happily issue any URL is how a careless script reaches an unpriced endpoint.
|
|
92
|
+
if (!path.startsWith('/inference/v1/') && !path.startsWith('/v1/accounts/')) {
|
|
93
|
+
throw new Error('fireworks: refusing to call an unmodeled path — Fireworks serves the inference plane under /inference/v1/ and the control plane under /v1/accounts/');
|
|
94
|
+
}
|
|
95
|
+
const headers: Record<string, string> = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
|
|
96
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
97
|
+
if (method === 'POST' || method === 'PATCH') {
|
|
98
|
+
headers['content-type'] = 'application/json';
|
|
99
|
+
init.body = JSON.stringify(body ?? {});
|
|
100
|
+
}
|
|
101
|
+
const weight = fireworksCallWeight(method, path);
|
|
102
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
103
|
+
const reservation = budget.checkBudget(weight);
|
|
104
|
+
const res = await doFetch(`${base}${path}`, init);
|
|
105
|
+
const resHeaders: Record<string, string> = {};
|
|
106
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
107
|
+
let data: unknown = {};
|
|
108
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
109
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown.
|
|
110
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
111
|
+
// call that louder refusal wins; an answer Fireworks ACCEPTED is kept, so a write that landed is
|
|
112
|
+
// never recorded as failed and performed again on retry.
|
|
113
|
+
try {
|
|
114
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!(error instanceof FireworksBudgetError) || !res.ok) throw error;
|
|
117
|
+
}
|
|
118
|
+
return { status: res.status, data };
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── PULL: real → twin (protocol 2 — the refresh adapter's fold) ─────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** The gateway list envelope: `{ <plural>: [...], nextPageToken, totalSize }`. */
|
|
125
|
+
function envelopeList(data: unknown, plural: string): Array<Record<string, unknown>> {
|
|
126
|
+
if (data === null || typeof data !== 'object') return [];
|
|
127
|
+
const items = (data as Record<string, unknown>)[plural];
|
|
128
|
+
return Array.isArray(items) ? (items as Array<Record<string, unknown>>) : [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The kernel subject id for a pulled row: `{account}/{id}` from the vendor's own resource
|
|
132
|
+
* `name` (`accounts/{account}/{collection}/{id}`). The twin's create path writes the same
|
|
133
|
+
* namespace, so created and pulled rows share ONE id space per (type, account). */
|
|
134
|
+
function namespacedId(name: string): string {
|
|
135
|
+
const seg = name.split('/');
|
|
136
|
+
return seg.length >= 4 ? `${seg[1]}/${seg[seg.length - 1]}` : (seg[seg.length - 1] ?? name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Map one gateway deployment row → a twin sync resource. Vendor `name` is
|
|
140
|
+
* `accounts/{account}/deployments/{id}`; the twin keys the resource by the id segment. */
|
|
141
|
+
export function mapDeployment(d: Record<string, unknown>): SyncResource {
|
|
142
|
+
const name = typeof d.name === 'string' ? d.name : '';
|
|
143
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
144
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
145
|
+
// ONE subject, never two.
|
|
146
|
+
const id = namespacedId(name);
|
|
147
|
+
return {
|
|
148
|
+
type: 'deployment',
|
|
149
|
+
id,
|
|
150
|
+
fields: {
|
|
151
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
152
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
153
|
+
// and every official client reading `.name` got undefined).
|
|
154
|
+
name,
|
|
155
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
156
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
157
|
+
_account: name.split('/')[1] ?? null,
|
|
158
|
+
displayName: d.displayName ?? null,
|
|
159
|
+
baseModel: d.baseModel ?? null,
|
|
160
|
+
state: d.state ?? 'STATE_UNSPECIFIED',
|
|
161
|
+
region: d.region ?? null,
|
|
162
|
+
replicaCount: d.replicaCount ?? null,
|
|
163
|
+
minReplicaCount: d.minReplicaCount ?? null,
|
|
164
|
+
maxReplicaCount: d.maxReplicaCount ?? null,
|
|
165
|
+
precision: d.precision ?? null,
|
|
166
|
+
createTime: d.createTime ?? null,
|
|
167
|
+
updateTime: d.updateTime ?? null,
|
|
168
|
+
status: (d.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function mapDataset(ds: Record<string, unknown>): SyncResource {
|
|
174
|
+
const name = typeof ds.name === 'string' ? ds.name : '';
|
|
175
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
176
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
177
|
+
// ONE subject, never two.
|
|
178
|
+
const id = namespacedId(name);
|
|
179
|
+
return {
|
|
180
|
+
type: 'dataset',
|
|
181
|
+
id,
|
|
182
|
+
fields: {
|
|
183
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
184
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
185
|
+
// and every official client reading `.name` got undefined).
|
|
186
|
+
name,
|
|
187
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
188
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
189
|
+
_account: name.split('/')[1] ?? null,
|
|
190
|
+
displayName: ds.displayName ?? null,
|
|
191
|
+
state: ds.state ?? 'STATE_UNSPECIFIED',
|
|
192
|
+
format: ds.format ?? null,
|
|
193
|
+
exampleCount: ds.exampleCount ?? null,
|
|
194
|
+
userUploaded: ds.userUploaded ?? null,
|
|
195
|
+
createTime: ds.createTime ?? null,
|
|
196
|
+
updateTime: ds.updateTime ?? null,
|
|
197
|
+
status: (ds.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function mapBatchInferenceJob(j: Record<string, unknown>): SyncResource {
|
|
203
|
+
const name = typeof j.name === 'string' ? j.name : '';
|
|
204
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
205
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
206
|
+
// ONE subject, never two.
|
|
207
|
+
const id = namespacedId(name);
|
|
208
|
+
return {
|
|
209
|
+
type: 'batchInferenceJob',
|
|
210
|
+
id,
|
|
211
|
+
fields: {
|
|
212
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
213
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
214
|
+
// and every official client reading `.name` got undefined).
|
|
215
|
+
name,
|
|
216
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
217
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
218
|
+
_account: name.split('/')[1] ?? null,
|
|
219
|
+
displayName: j.displayName ?? null,
|
|
220
|
+
model: j.model ?? null,
|
|
221
|
+
inputDatasetId: j.inputDatasetId ?? null,
|
|
222
|
+
outputDatasetId: j.outputDatasetId ?? null,
|
|
223
|
+
state: j.state ?? 'JOB_STATE_UNSPECIFIED',
|
|
224
|
+
createTime: j.createTime ?? null,
|
|
225
|
+
updateTime: j.updateTime ?? null,
|
|
226
|
+
status: (j.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function mapSupervisedFineTuningJob(j: Record<string, unknown>): SyncResource {
|
|
232
|
+
const name = typeof j.name === 'string' ? j.name : '';
|
|
233
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
234
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
235
|
+
// ONE subject, never two.
|
|
236
|
+
const id = namespacedId(name);
|
|
237
|
+
return {
|
|
238
|
+
type: 'supervisedFineTuningJob',
|
|
239
|
+
id,
|
|
240
|
+
fields: {
|
|
241
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
242
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
243
|
+
// and every official client reading `.name` got undefined).
|
|
244
|
+
name,
|
|
245
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
246
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
247
|
+
_account: name.split('/')[1] ?? null,
|
|
248
|
+
displayName: j.displayName ?? null,
|
|
249
|
+
baseModel: j.baseModel ?? null,
|
|
250
|
+
dataset: j.dataset ?? null,
|
|
251
|
+
state: j.state ?? 'JOB_STATE_UNSPECIFIED',
|
|
252
|
+
createTime: j.createTime ?? null,
|
|
253
|
+
updateTime: j.updateTime ?? null,
|
|
254
|
+
status: (j.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function mapUser(u: Record<string, unknown>): SyncResource {
|
|
260
|
+
const name = typeof u.name === 'string' ? u.name : '';
|
|
261
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
262
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
263
|
+
// ONE subject, never two.
|
|
264
|
+
const id = namespacedId(name);
|
|
265
|
+
return {
|
|
266
|
+
type: 'user',
|
|
267
|
+
id,
|
|
268
|
+
fields: {
|
|
269
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
270
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
271
|
+
// and every official client reading `.name` got undefined).
|
|
272
|
+
name,
|
|
273
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
274
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
275
|
+
_account: name.split('/')[1] ?? null,
|
|
276
|
+
displayName: u.displayName ?? null,
|
|
277
|
+
email: u.email ?? null,
|
|
278
|
+
role: u.role ?? null,
|
|
279
|
+
state: u.state ?? 'STATE_UNSPECIFIED',
|
|
280
|
+
createTime: u.createTime ?? null,
|
|
281
|
+
updateTime: u.updateTime ?? null,
|
|
282
|
+
status: (u.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function mapModel(m: Record<string, unknown>): SyncResource {
|
|
288
|
+
const name = typeof m.name === 'string' ? m.name : '';
|
|
289
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
290
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
291
|
+
// ONE subject, never two.
|
|
292
|
+
const id = namespacedId(name);
|
|
293
|
+
return {
|
|
294
|
+
type: 'model',
|
|
295
|
+
id,
|
|
296
|
+
fields: {
|
|
297
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
298
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
299
|
+
// and every official client reading `.name` got undefined).
|
|
300
|
+
name,
|
|
301
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
302
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
303
|
+
_account: name.split('/')[1] ?? null,
|
|
304
|
+
displayName: m.displayName ?? null,
|
|
305
|
+
state: m.state ?? 'STATE_UNSPECIFIED',
|
|
306
|
+
public: m.public ?? null,
|
|
307
|
+
contextLength: m.contextLength ?? null,
|
|
308
|
+
createTime: m.createTime ?? null,
|
|
309
|
+
updateTime: m.updateTime ?? null,
|
|
310
|
+
status: (m.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Secrets never carry their value on a list read (the vendor redacts it), so the map stores the
|
|
316
|
+
* metadata only — mirroring the vendor's own read surface. */
|
|
317
|
+
export function mapSecret(s: Record<string, unknown>): SyncResource {
|
|
318
|
+
const name = typeof s.name === 'string' ? s.name : '';
|
|
319
|
+
// The kernel subject is the ACCOUNT-NAMESPACED id (`{account}/{id}`) — the same space the
|
|
320
|
+
// twin's own create path writes — so a pulled row and a created row of one vendor resource are
|
|
321
|
+
// ONE subject, never two.
|
|
322
|
+
const id = namespacedId(name);
|
|
323
|
+
return {
|
|
324
|
+
type: 'secret',
|
|
325
|
+
id,
|
|
326
|
+
fields: {
|
|
327
|
+
// The vendor's own identity field — the SAME shape the twin's own creates store, so one
|
|
328
|
+
// collection never has two shapes (round-four review: a pulled row served `vendor_name`
|
|
329
|
+
// and every official client reading `.name` got undefined).
|
|
330
|
+
name,
|
|
331
|
+
// The account the row belongs to (the vendor name's own second segment) — the twin's
|
|
332
|
+
// reads are account-scoped, so a pulled row must carry the tenancy it was pulled under.
|
|
333
|
+
_account: name.split('/')[1] ?? null,
|
|
334
|
+
keyName: s.keyName ?? null,
|
|
335
|
+
state: s.state ?? 'STATE_UNSPECIFIED',
|
|
336
|
+
createTime: s.createTime ?? null,
|
|
337
|
+
updateTime: s.updateTime ?? null,
|
|
338
|
+
status: (s.status as { code?: string; message?: string } | null | undefined) ?? null,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Which collections a pull enumerates, with their mappers. Paginated with the vendor's own
|
|
344
|
+
* `pageToken` cursor so a large account is not truncated at page one. */
|
|
345
|
+
const PULL_COLLECTIONS: Array<{ plural: string; type: string; map: (r: Record<string, unknown>) => SyncResource }> = [
|
|
346
|
+
{ plural: 'deployments', type: 'deployment', map: mapDeployment },
|
|
347
|
+
{ plural: 'datasets', type: 'dataset', map: mapDataset },
|
|
348
|
+
{ plural: 'batchInferenceJobs', type: 'batchInferenceJob', map: mapBatchInferenceJob },
|
|
349
|
+
{ plural: 'supervisedFineTuningJobs', type: 'supervisedFineTuningJob', map: mapSupervisedFineTuningJob },
|
|
350
|
+
{ plural: 'users', type: 'user', map: mapUser },
|
|
351
|
+
{ plural: 'models', type: 'model', map: mapModel },
|
|
352
|
+
{ plural: 'secrets', type: 'secret', map: mapSecret },
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
/** Fetch the account's control-plane state through the injected client and map to SyncResource[]
|
|
356
|
+
* (no fold). `accountId` scopes every list; a missing/failed list throws — a REFUSED pull is NOT
|
|
357
|
+
* an empty account, and folding an empty list over real observed state would tombstone it. */
|
|
358
|
+
export async function pullFireworksState(execute: FireworksExecute, accountId: string): Promise<SyncResource[]> {
|
|
359
|
+
const resources: SyncResource[] = [];
|
|
360
|
+
for (const col of PULL_COLLECTIONS) {
|
|
361
|
+
let pageToken: string | null = null;
|
|
362
|
+
// An independent page ceiling: pagination terminates on `nextPageToken` becoming null, but a
|
|
363
|
+
// vendor (or a scenario handler) echoing a token forever must end in a thrown error, not an
|
|
364
|
+
// endless loop — no single response field can make this loop unbounded.
|
|
365
|
+
for (let page = 0; page < MAX_PULL_PAGES; page++) {
|
|
366
|
+
const q = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}&pageSize=200` : '?pageSize=200';
|
|
367
|
+
const res = await execute('GET', `/v1/accounts/${encodeURIComponent(accountId)}/${col.plural}${q}`);
|
|
368
|
+
if (res.status < 200 || res.status >= 300) {
|
|
369
|
+
throw new Error(`fireworks pull ${col.plural} failed: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
|
|
370
|
+
}
|
|
371
|
+
for (const row of envelopeList(res.data, col.plural)) resources.push(col.map(row));
|
|
372
|
+
pageToken = typeof (res.data as Record<string, unknown> | null)?.nextPageToken === 'string' ? ((res.data as Record<string, unknown>).nextPageToken as string) : null;
|
|
373
|
+
if (!pageToken) break;
|
|
374
|
+
}
|
|
375
|
+
if (pageToken !== null) throw new Error(`fireworks pull ${col.plural} did not terminate after ${MAX_PULL_PAGES} pages — refusing to loop forever on a vendor that keeps returning nextPageToken`);
|
|
376
|
+
}
|
|
377
|
+
return resources;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The pagination ceiling above: 10 000 rows at pageSize 200 per collection — far above any real
|
|
381
|
+
* account, far below forever. */
|
|
382
|
+
const MAX_PULL_PAGES = 50;
|
|
383
|
+
|
|
384
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant.
|
|
385
|
+
*
|
|
386
|
+
* COMPLETENESS IS PER ACCOUNT: the pull observes ONE account, so the tombstone sweep it arms
|
|
387
|
+
* must reach only that account's rows. The kernel's `complete` is type-level — it tombstones
|
|
388
|
+
* every subject of the type the batch does not hold — so folding account B's listing with
|
|
389
|
+
* `complete: ['deployment', …]` erased account A's rows (the sweep never saw them in the
|
|
390
|
+
* batch). The pack therefore expresses completeness itself, the way the kernel's explicit
|
|
391
|
+
* tombstones allow: every row the projection holds for THIS account's collections that the
|
|
392
|
+
* listing did not name is observed as `{deleted: true}` in the same batch, and NO type-level
|
|
393
|
+
* `complete` is armed. A foreign account's rows are invisible to the sweep by construction,
|
|
394
|
+
* and a vanished resource still gets its tombstone. */
|
|
395
|
+
function fold(resources: SyncResource[], opts: { accountId: string; root?: string; occurredAt?: string }) {
|
|
396
|
+
const at = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
397
|
+
const observed = resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields }));
|
|
398
|
+
const seen = new Set(observed.map((r) => `${r.type}:${r.id}`));
|
|
399
|
+
for (const row of projectResources(SERVICE, opts.root)) {
|
|
400
|
+
if (!PULL_TYPES.has(row.type)) continue;
|
|
401
|
+
if (row._account !== opts.accountId) continue; // another account's row — never this sweep's
|
|
402
|
+
if (seen.has(`${row.type}:${row.id}`)) continue;
|
|
403
|
+
if (row.deleted === true) continue; // already tombstoned
|
|
404
|
+
observed.push({ type: row.type, id: row.id, fields: { deleted: true } });
|
|
405
|
+
}
|
|
406
|
+
return observeResources(SERVICE, observed, {
|
|
407
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:fireworks:${at}`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The collection types a pull enumerates — the ones completeness is expressed over. */
|
|
412
|
+
const PULL_TYPES = new Set(PULL_COLLECTIONS.map((c) => c.type));
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* D7 consumer-facing pull entry point: gather all Fireworks control-plane read domains and fold
|
|
416
|
+
* them into the twin through a SINGLE observed batch, returning the standard result shape.
|
|
417
|
+
* Idempotent: a re-pull of identical state appends no new deltas.
|
|
418
|
+
*/
|
|
419
|
+
export async function syncFireworksFromReal(
|
|
420
|
+
execute: FireworksExecute,
|
|
421
|
+
opts: { accountId: string; root?: string; occurredAt?: string } = { accountId: '' },
|
|
422
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
423
|
+
const resources = await pullFireworksState(execute, opts.accountId);
|
|
424
|
+
const result = fold(resources, opts);
|
|
425
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── PUSH: twin → real ───────────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The gateway create ids are QUERY params (deployments: deploymentId, users: userId) or body
|
|
432
|
+
* fields (datasets), per the vendor's own spec — not path segments. A body field is carried
|
|
433
|
+
* through the body; a query param is appended here.
|
|
434
|
+
*/
|
|
435
|
+
function createQueryString(type: string, id: string): string {
|
|
436
|
+
if (type === 'deployment' || type === 'user' || type === 'batchInferenceJob' || type === 'supervisedFineTuningJob') {
|
|
437
|
+
return `?${type}Id=${encodeURIComponent(id)}`;
|
|
438
|
+
}
|
|
439
|
+
return '';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Map a subject type → its Gateway REST collection segment. */
|
|
443
|
+
const COLLECTION_SEGMENT: Record<string, string> = {
|
|
444
|
+
deployment: 'deployments',
|
|
445
|
+
dataset: 'datasets',
|
|
446
|
+
batchInferenceJob: 'batchInferenceJobs',
|
|
447
|
+
supervisedFineTuningJob: 'supervisedFineTuningJobs',
|
|
448
|
+
user: 'users',
|
|
449
|
+
model: 'models',
|
|
450
|
+
secret: 'secrets',
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/** The twin's LOCALLY-MINTED id namespace (see `nextId` in fireworks-twin.ts). A subject still
|
|
454
|
+
* bearing one has no counterpart in the real account. The `_twin_` infix is anchored so a PULLED
|
|
455
|
+
* vendor id can never match. */
|
|
456
|
+
const LOCAL_ID = /_twin_\d+$/;
|
|
457
|
+
|
|
458
|
+
/** The BARE id a vendor path segment carries: kernel subjects are account-namespaced
|
|
459
|
+
* (`{account}/{id}` — see `namespacedId`), and the Gateway REST path addresses the resource by
|
|
460
|
+
* the id segment alone. A bare id (a connector-verify action written directly) passes through. */
|
|
461
|
+
function bareId(subjectId: string): string {
|
|
462
|
+
return subjectId.includes('/') ? subjectId.slice(subjectId.indexOf('/') + 1) : subjectId;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Why this operation cannot be pushed to the real vendor, or null if it can. Pure — no vendor
|
|
466
|
+
* call on its path. Inference operations are the big class: replaying a completion at the vendor
|
|
467
|
+
* spends real money and stores nothing, so a push of one is a category error, not a gap. */
|
|
468
|
+
export function unpushableReason(op: string): string | null {
|
|
469
|
+
if (op.startsWith('response.') || op === 'chat.completion' || op === 'completion') {
|
|
470
|
+
return 'inference is generative and stores nothing — replaying it at the vendor would spend real money for no stored state; the twin records it locally only';
|
|
471
|
+
}
|
|
472
|
+
if (op.endsWith('.cancel') || op.endsWith('.resume') || op.endsWith('.undelete') || op.endsWith('.scale')) {
|
|
473
|
+
return `the custom verb '${op.slice(op.indexOf('.') + 1)}' has no push mapping in this pack — the twin models it for wire fidelity, the connector replays only create/delete`;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The REST (method, path, body) that CONFIRMS one local action against the real Gateway REST
|
|
480
|
+
* surface. Create ids go where the vendor's spec puts them (query params for deployments/users,
|
|
481
|
+
* the body for the rest). A locally-minted id on anything but a create is REFUSED — the twin's
|
|
482
|
+
* own mint is not an address the vendor knows.
|
|
483
|
+
*/
|
|
484
|
+
export function fireworksRequestForAction(
|
|
485
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
486
|
+
accountId: string,
|
|
487
|
+
opts: { externalId?: string } = {},
|
|
488
|
+
): { method: 'POST' | 'DELETE'; path: string; body?: Record<string, unknown> } {
|
|
489
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
490
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
491
|
+
const segment = COLLECTION_SEGMENT[action.subject.type];
|
|
492
|
+
if (!segment) throw new Error(`fireworks push: no REST collection for subject type '${action.subject.type}'`);
|
|
493
|
+
const fields = (action.fields ?? {}) as Record<string, unknown>;
|
|
494
|
+
if (verb === 'create') {
|
|
495
|
+
const id = bareId(action.subject.id);
|
|
496
|
+
const path = `/v1/accounts/${encodeURIComponent(accountId)}/${segment}${createQueryString(action.subject.type, id)}`;
|
|
497
|
+
// The body IS the resource for deployments, jobs, users and secrets (the vendor's spec takes
|
|
498
|
+
// the resource directly); datasets and models wrap ({dataset, datasetId} / {model, modelId}).
|
|
499
|
+
if (action.subject.type === 'dataset') return { method: 'POST', path, body: { dataset: fields, datasetId: id } };
|
|
500
|
+
if (action.subject.type === 'model') return { method: 'POST', path, body: { model: fields, modelId: id } };
|
|
501
|
+
return { method: 'POST', path, body: fields };
|
|
502
|
+
}
|
|
503
|
+
if (verb === 'delete') {
|
|
504
|
+
const localId = bareId(action.subject.id);
|
|
505
|
+
const vendorId = !LOCAL_ID.test(localId) ? localId
|
|
506
|
+
: opts.externalId ?? null;
|
|
507
|
+
if (vendorId === null) {
|
|
508
|
+
throw new Error(`fireworks push: refusing to address the real account by the twin's own id '${localId}' — no vendor id was ever recorded for this subject`);
|
|
509
|
+
}
|
|
510
|
+
return { method: 'DELETE', path: `/v1/accounts/${encodeURIComponent(accountId)}/${segment}/${encodeURIComponent(vendorId)}` };
|
|
511
|
+
}
|
|
512
|
+
throw new Error(`fireworks push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Push ONE pending action to REAL Fireworks via the injected executor. Returns the real external
|
|
517
|
+
* id (the vendor's own id from the create response's `name`, or the addressed id on delete).
|
|
518
|
+
* WRITES TO THE REAL ACCOUNT.
|
|
519
|
+
*/
|
|
520
|
+
export async function pushFireworksAction(
|
|
521
|
+
execute: FireworksExecute,
|
|
522
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
523
|
+
accountId: string,
|
|
524
|
+
opts: { externalId?: string } = {},
|
|
525
|
+
): Promise<{ externalId: string }> {
|
|
526
|
+
const req = fireworksRequestForAction(action, accountId, opts);
|
|
527
|
+
const res = await execute(req.method, req.path, req.body);
|
|
528
|
+
if (res.status < 200 || res.status >= 300) {
|
|
529
|
+
throw new Error(`fireworks push ${action.operation} refused: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
|
|
530
|
+
}
|
|
531
|
+
const data = res.data as Record<string, unknown> | null;
|
|
532
|
+
// The vendor's create response carries the resource with its `name`
|
|
533
|
+
// (accounts/{account}/<plural>/<id>); the id segment is the external id. A create response
|
|
534
|
+
// without one is REFUSED, never guessed: falling back to the twin's own mint would record a
|
|
535
|
+
// vendor address the vendor never issued. (A delete answers `{}` — its external id is the
|
|
536
|
+
// address it just acted on.)
|
|
537
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
538
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
539
|
+
const name = typeof data?.name === 'string' ? data.name : '';
|
|
540
|
+
const externalId = name.split('/').pop() || opts.externalId;
|
|
541
|
+
if (verb === 'create' && !externalId) {
|
|
542
|
+
throw new Error(`fireworks push ${op}: the vendor response named no resource — refusing to record the twin's own id '${action.subject.id}' as the external id`);
|
|
543
|
+
}
|
|
544
|
+
return { externalId: externalId || action.subject.id };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Push the twin's PENDING local actions to real Fireworks and CONFIRM each. Idempotent: a
|
|
549
|
+
* confirmed action is no longer pending, so a re-push enacts NOTHING.
|
|
550
|
+
*
|
|
551
|
+
* An action this connector cannot faithfully push is SKIPPED AND REPORTED, never confirmed and
|
|
552
|
+
* never silently dropped: it stays pending and is named in `refused`. A create's real id is
|
|
553
|
+
* recorded on the resource as `_external_id` at confirm time, so a later delete addresses the
|
|
554
|
+
* VENDOR by it — never the twin's own mint.
|
|
555
|
+
*/
|
|
556
|
+
export async function pushPendingFireworksActions(
|
|
557
|
+
execute: FireworksExecute,
|
|
558
|
+
opts: { accountId: string; root?: string; occurredAt?: string },
|
|
559
|
+
): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; refused: Array<{ actionId: string; operation: string; reason: string }> }> {
|
|
560
|
+
const confirmed: string[] = [];
|
|
561
|
+
const externalIds: Record<string, string> = {};
|
|
562
|
+
const refused: Array<{ actionId: string; operation: string; reason: string }> = [];
|
|
563
|
+
const at = opts.occurredAt ?? new Date().toISOString();
|
|
564
|
+
for (const action of deployableEntries(SERVICE, opts.root)) {
|
|
565
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
566
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
567
|
+
if (verb !== 'create' && verb !== 'delete') {
|
|
568
|
+
refused.push({ actionId: action.id, operation: op, reason: unpushableReason(op) ?? `unsupported operation '${op}'` });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const why = unpushableReason(op);
|
|
572
|
+
if (why) { refused.push({ actionId: action.id, operation: op, reason: why }); continue; }
|
|
573
|
+
// Anything but a create must address the vendor by the id the vendor issued. A locally-minted
|
|
574
|
+
// subject with none recorded is REFUSED, never guessed.
|
|
575
|
+
let external: string | undefined;
|
|
576
|
+
if (verb !== 'create' && LOCAL_ID.test(bareId(action.subject.id))) {
|
|
577
|
+
const row = projectResources(SERVICE, opts.root).find((r) => r.type === action.subject.type && r.id === action.subject.id);
|
|
578
|
+
const ext = row?._external_id;
|
|
579
|
+
if (typeof ext !== 'string' || !ext) {
|
|
580
|
+
refused.push({ actionId: action.id, operation: op, reason: `no vendor id recorded for ${action.subject.type} '${action.subject.id}' — it was never pushed to the real account, so there is nothing there to ${verb}` });
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
external = ext;
|
|
584
|
+
}
|
|
585
|
+
let externalId: string;
|
|
586
|
+
try {
|
|
587
|
+
({ externalId } = await pushFireworksAction(execute, action, opts.accountId, ...(external !== undefined ? [{ externalId: external }] as const : [] as const)));
|
|
588
|
+
} catch (err) {
|
|
589
|
+
// A push that cannot be confirmed faithfully (a vendor response that names no resource, a
|
|
590
|
+
// locally-minted delete address) is REPORTED and left pending — the sweep continues.
|
|
591
|
+
refused.push({ actionId: action.id, operation: op, reason: err instanceof Error ? err.message : String(err) });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
// Record the id the vendor issued ON the resource, so a later delete can address it.
|
|
595
|
+
confirmAction({
|
|
596
|
+
service: SERVICE, actionId: action.id, subject: action.subject,
|
|
597
|
+
fields: { ...(action.fields ?? {}), ...(verb === 'create' ? { _external_id: externalId } : {}) },
|
|
598
|
+
occurredAt: at, ...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
599
|
+
});
|
|
600
|
+
confirmed.push(action.id);
|
|
601
|
+
externalIds[action.id] = externalId;
|
|
602
|
+
}
|
|
603
|
+
return { pushed: confirmed.length, confirmed, externalIds, refused };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── FULL bi-directional sync ────────────────────────────────────────────────────────────────
|
|
607
|
+
/**
|
|
608
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
|
|
609
|
+
* Fireworks and confirm it, then (2) PULL the control-plane collections back and fold them into
|
|
610
|
+
* the event log. Pushing first means the pull observes the twin's own writes as confirmed external
|
|
611
|
+
* state (no double-count). Re-running with no pending writes and identical real state is a no-op.
|
|
612
|
+
*/
|
|
613
|
+
export async function fullSyncFireworks(
|
|
614
|
+
execute: FireworksExecute,
|
|
615
|
+
opts: { accountId: string; root?: string; occurredAt?: string },
|
|
616
|
+
): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number; refused: Array<{ actionId: string; operation: string; reason: string }> }> {
|
|
617
|
+
const push = await pushPendingFireworksActions(execute, opts);
|
|
618
|
+
const resources = await pullFireworksState(execute, opts.accountId);
|
|
619
|
+
const pull = fold(resources, opts);
|
|
620
|
+
return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length, refused: push.refused };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
/** The one HTTP call every adapter below makes, over the kernel's executor. At a REAL boundary the
|
|
626
|
+
* kernel sets the sealed credential over these headers (executor.ts); at the twin's own wire any
|
|
627
|
+
* credential is one. */
|
|
628
|
+
function fireworksSend(execute: RemoteExecute): FireworksExecute {
|
|
629
|
+
return async (method, path, body) => {
|
|
630
|
+
const res = await execute({
|
|
631
|
+
method, path,
|
|
632
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer fireworks_twin_bootstrap_token' },
|
|
633
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
634
|
+
});
|
|
635
|
+
let parsed: unknown = {};
|
|
636
|
+
try { parsed = JSON.parse(res.body || '{}'); } catch { parsed = {}; }
|
|
637
|
+
return { status: res.status, data: parsed };
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** A `FireworksExecute` over the kernel's executor — the adapter seam protocol 2 fixes. */
|
|
642
|
+
export function fireworksExecuteOver(execute: RemoteExecute): FireworksExecute {
|
|
643
|
+
return fireworksSend(execute);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The refresh adapter: pull the account's control-plane collections. `origin` is unused —
|
|
647
|
+
* Fireworks' surface is single-host, so the executor's own base URL is the address. */
|
|
648
|
+
export async function syncFireworksFromRemote(
|
|
649
|
+
execute: RemoteExecute,
|
|
650
|
+
opts: { root?: string; origin?: string; occurredAt?: string; accountId?: string } = {},
|
|
651
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
652
|
+
return syncFireworksFromReal(fireworksExecuteOver(execute), {
|
|
653
|
+
accountId: opts.accountId ?? FIREWORKS_ACCOUNT_ID,
|
|
654
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
655
|
+
...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The perform adapter — the head's executor for a local write at a REAL boundary. Create/delete
|
|
661
|
+
* replay against the Gateway REST surface through `ctx.resolve` (a locally minted subject is
|
|
662
|
+
* addressed by the vendor id the projection recorded, never by the mint). Inference operations and
|
|
663
|
+
* custom verbs are NOT performed: they are the twin's own record, and the outcome says so.
|
|
664
|
+
*/
|
|
665
|
+
export async function performFireworksAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
|
|
666
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
667
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
668
|
+
if (verb !== 'create' && verb !== 'delete') {
|
|
669
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — ${unpushableReason(op) ?? 'the connector does not replay it'}` } };
|
|
670
|
+
}
|
|
671
|
+
// The account rides ON THE SUBJECT: kernel subject ids are account-namespaced
|
|
672
|
+
// (`{account}/{id}` — the same grammar the create/pull paths write), so the perform path
|
|
673
|
+
// derives the Gateway account from the subject's own namespace. Hardcoding the pack constant
|
|
674
|
+
// here made a perform of any OTHER account's subject address /v1/accounts/my-account/… — the
|
|
675
|
+
// wrong tenant's REST surface. A bare subject id (a connector-verify action written directly)
|
|
676
|
+
// falls back to the pack's default account.
|
|
677
|
+
const accountId = action.subject.id.includes('/') ? action.subject.id.slice(0, action.subject.id.indexOf('/')) : FIREWORKS_ACCOUNT_ID;
|
|
678
|
+
const row = projectResources(SERVICE, ctx.root).find((r) => r.type === action.subject.type && r.id === action.subject.id);
|
|
679
|
+
const recordedExternal = typeof row?._external_id === 'string' ? row._external_id : undefined;
|
|
680
|
+
const bare = bareId(action.subject.id);
|
|
681
|
+
const vendorId = verb === 'create'
|
|
682
|
+
? bare
|
|
683
|
+
: (!LOCAL_ID.test(bare) ? bare : recordedExternal ?? null);
|
|
684
|
+
if (vendorId === null) {
|
|
685
|
+
throw new Error(`fireworks perform ${op} refused: the subject '${action.subject.id}' was never pushed, so the vendor holds no row to ${verb}`);
|
|
686
|
+
}
|
|
687
|
+
const req = fireworksRequestForAction(action, accountId, ...(vendorId !== action.subject.id ? [{ externalId: vendorId }] as const : [] as const));
|
|
688
|
+
const res = await fireworksExecuteOver(execute)(req.method, req.path, req.body);
|
|
689
|
+
if (res.status < 200 || res.status >= 300) {
|
|
690
|
+
throw new Error(`fireworks perform ${op} refused: HTTP ${res.status} ${JSON.stringify(res.data).slice(0, 200)}`);
|
|
691
|
+
}
|
|
692
|
+
const data = res.data as Record<string, unknown> | null;
|
|
693
|
+
const name = typeof data?.name === 'string' ? data.name : '';
|
|
694
|
+
const externalId = name.split('/').pop() || vendorId;
|
|
695
|
+
return { externalId, data: (data ?? {}) as Record<string, unknown> };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The ledger path is re-exported so an operator can find the spend file without importing the
|
|
699
|
+
// budget module separately.
|
|
700
|
+
export { fireworksBudgetPath };
|