@vivix-labs/event-statistics 0.0.0-stage → 1.0.0-beta

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.
@@ -0,0 +1,8 @@
1
+ import { ServerContext } from 'vivix-studio/sdk/server';
2
+ import { z } from 'vivix-studio/sdk/shared';
3
+ import { Config } from './shared.js';
4
+ export { dependencies } from './shared.js';
5
+
6
+ declare function activate(ctx: ServerContext<z.infer<typeof Config>>): Promise<void>;
7
+
8
+ export { Config, activate as default };
package/dist/server.js ADDED
@@ -0,0 +1,331 @@
1
+ // packages/event-statistics/src/server.ts
2
+ import { createHash } from "crypto";
3
+ import { defineOperation } from "vivix-studio/sdk/server";
4
+ import { z as z4 } from "vivix-studio/sdk/shared";
5
+
6
+ // packages/project-studio/src/events.ts
7
+ import { z as z2 } from "vivix-studio/sdk/shared";
8
+
9
+ // packages/vivix-api/src/billing.ts
10
+ import { z } from "vivix-studio/sdk/shared";
11
+ var taskBilling = z.object({
12
+ status: z.enum(["pending", "settled", "released", "unavailable"]),
13
+ credits: z.string().regex(/^\d+(?:\.\d+)?$/).nullable()
14
+ }).strict().refine(
15
+ (value) => value.status === "settled" ? value.credits !== null : value.status === "released" ? value.credits === "0" : value.credits === null
16
+ );
17
+ function readTaskBilling(response) {
18
+ const parsed = taskBilling.safeParse(response?.billing);
19
+ return parsed.success ? parsed.data : { status: "unavailable", credits: null };
20
+ }
21
+
22
+ // packages/project-studio/src/events.ts
23
+ var taskEventNames = [
24
+ "project-studio/task.completed",
25
+ "project-studio/task.failed",
26
+ "project-studio/task.cancelled",
27
+ "project-studio/billing.updated"
28
+ ];
29
+ var result = z2.object({
30
+ remoteTaskId: z2.string(),
31
+ outputIndex: z2.number().int().nonnegative(),
32
+ assetId: z2.string().optional()
33
+ }).strict();
34
+ var taskNotice = z2.object({
35
+ taskId: z2.string(),
36
+ attemptId: z2.string(),
37
+ revision: z2.number().int().positive(),
38
+ requestedCount: z2.number().int().positive().nullable(),
39
+ resultCount: z2.number().int().nonnegative().nullable(),
40
+ partialResults: z2.boolean().nullable(),
41
+ results: z2.array(result)
42
+ }).strict();
43
+ var billingNotice = z2.object({
44
+ taskId: z2.string(),
45
+ attemptId: z2.string(),
46
+ billingRecordId: z2.string(),
47
+ revision: z2.number().int().positive()
48
+ }).strict();
49
+ var eventDefinitions = taskEventNames.map((name) => ({
50
+ name,
51
+ version: 1,
52
+ mode: "notification",
53
+ schema: name.endsWith("billing.updated") ? "schemas/billing-notice.json" : "schemas/task-notice.json",
54
+ scope: "project"
55
+ }));
56
+ var stateKey = "project-studio.state";
57
+ function saved(record) {
58
+ const value = record.metadata?.[stateKey];
59
+ return value?.format === 1 && value.remote && Number.isSafeInteger(value.remote.revision) ? value : void 0;
60
+ }
61
+ function readableResults(record) {
62
+ if (typeof record.data.remoteTaskId !== "string" || !record.data.remoteTaskId) return [];
63
+ return (record.data.remoteOutputs || []).flatMap((value, outputIndex) => {
64
+ if (!value || typeof value !== "object") return [];
65
+ const output = value;
66
+ return ["asset_id", "file_id", "id", "url", "download_url", "content", "text"].some(
67
+ (k) => typeof output[k] === "string" && Boolean(output[k])
68
+ ) ? [{ remoteTaskId: record.data.remoteTaskId, outputIndex }] : [];
69
+ });
70
+ }
71
+ function outcome(record) {
72
+ const d = record.data;
73
+ if (d.remoteStatus === "succeeded" && readableResults(record).length) return "succeeded";
74
+ if (d.remoteStatus === "failed") return "failed";
75
+ if (["cancelled", "canceled"].includes(d.remoteStatus) || d.state === "canceled" && !d.submittedAt && !d.remoteTaskId && d.attemptCount === 0)
76
+ return "cancelled";
77
+ return "unknown";
78
+ }
79
+ function taskState(record) {
80
+ const old = saved(record), status = outcome(record);
81
+ const requestedCount = Number.isInteger(record.data.inputSnapshot?.outputCount) ? record.data.inputSnapshot.outputCount : null;
82
+ const resultSignature = JSON.stringify(readableResults(record));
83
+ const storedCount = Array.isArray(record.data.remoteOutputs) ? record.data.remoteOutputs.length : null;
84
+ const unchanged = old?.remote.status === status && old.remote.requestedCount === requestedCount && old.remote.resultSignature === resultSignature && (old.remote.resultCount === null || old.remote.resultCount === storedCount);
85
+ const resultCount = status === "unknown" ? null : unchanged ? old.remote.resultCount : record.data.remoteOutputs?.length ? record.data.remoteOutputs.length : null;
86
+ return {
87
+ format: 1,
88
+ remote: {
89
+ status,
90
+ revision: unchanged ? old.remote.revision : record.revision,
91
+ requestedCount,
92
+ resultCount,
93
+ partialResults: requestedCount !== null && resultCount !== null ? resultCount < requestedCount : null,
94
+ resultSignature
95
+ },
96
+ billing: currentBilling(record, old?.billing)
97
+ };
98
+ }
99
+ function billingViewFromI4(record, cost, revision) {
100
+ const { status, credits } = readTaskBilling({
101
+ billing: { status: cost.status, credits: cost.credits }
102
+ });
103
+ return {
104
+ billingRecordId: JSON.stringify([
105
+ record.storeId,
106
+ record.scope?.id,
107
+ record.objectId,
108
+ "task.cost"
109
+ ]),
110
+ taskId: record.objectId,
111
+ attemptId: record.objectId,
112
+ revision,
113
+ status,
114
+ amount: status === "settled" || status === "released" ? { value: credits.replace(/^0+(?=\d)/, ""), unit: "credits" } : null
115
+ };
116
+ }
117
+ function normalizeDecimal(value) {
118
+ const [whole, fraction = ""] = value.split(".");
119
+ const trimmed = fraction.replace(/0+$/, "");
120
+ return whole.replace(/^0+(?=\d)/, "") + (trimmed ? "." + trimmed : "");
121
+ }
122
+ function billingDigest(view) {
123
+ return JSON.stringify([
124
+ view.status,
125
+ view.amount && { ...view.amount, value: normalizeDecimal(view.amount.value) }
126
+ ]);
127
+ }
128
+ function currentBilling(record, old) {
129
+ const view = billingViewFromI4(
130
+ record,
131
+ record.data.cost ?? { status: "unavailable", credits: null },
132
+ record.revision
133
+ );
134
+ const semanticDigest = billingDigest(view);
135
+ return {
136
+ billingRecordId: view.billingRecordId,
137
+ semanticDigest,
138
+ revision: old?.semanticDigest === semanticDigest ? old.revision : record.revision
139
+ };
140
+ }
141
+ function taskBillingView(record) {
142
+ const state = taskState(record);
143
+ return billingViewFromI4(
144
+ record,
145
+ record.data.cost ?? { status: "unavailable", credits: null },
146
+ state.billing?.revision ?? record.revision
147
+ );
148
+ }
149
+
150
+ // packages/event-statistics/src/shared.ts
151
+ import { z as z3 } from "vivix-studio/sdk/shared";
152
+ var Config = z3.object({
153
+ studioInstance: z3.string().default("studio"),
154
+ dataInstance: z3.string().default("user:data")
155
+ }).strict();
156
+ var dependencies = (config) => [
157
+ config.studioInstance,
158
+ config.dataInstance
159
+ ];
160
+ function billingTotals(values) {
161
+ const latest = /* @__PURE__ */ new Map();
162
+ for (const value of values) {
163
+ const old = latest.get(value.billingRecordId);
164
+ if (!old || value.revision > old.revision) latest.set(value.billingRecordId, value);
165
+ }
166
+ const groups = /* @__PURE__ */ new Map();
167
+ let unknown = 0;
168
+ for (const bill of latest.values()) {
169
+ if (!bill.amount || !bill.amount.unit || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(bill.amount.value)) {
170
+ unknown++;
171
+ continue;
172
+ }
173
+ const [whole, fraction = ""] = bill.amount.value.split("."), scale = fraction.length;
174
+ const old = groups.get(bill.amount.unit) || { value: 0n, scale: 0 };
175
+ const combined = Math.max(scale, old.scale);
176
+ groups.set(bill.amount.unit, {
177
+ value: old.value * 10n ** BigInt(combined - old.scale) + BigInt(whole + fraction) * 10n ** BigInt(combined - scale),
178
+ scale: combined
179
+ });
180
+ }
181
+ return {
182
+ unknown,
183
+ units: Object.fromEntries(
184
+ [...groups].map(([unit, { value, scale }]) => {
185
+ const s = value.toString().padStart(scale + 1, "0");
186
+ return [unit, scale ? s.slice(0, -scale) + "." + s.slice(-scale) : s];
187
+ })
188
+ )
189
+ };
190
+ }
191
+
192
+ // packages/event-statistics/src/server.ts
193
+ async function activate(ctx) {
194
+ if (ctx.scope?.kind !== "project") throw Error("Install the statistics example in a project.");
195
+ const studio = ctx.plugins.get(ctx.config.studioInstance);
196
+ const data = ctx.plugins.get(ctx.config.dataInstance).bind({
197
+ scope: ctx.scope,
198
+ ownerPackage: "@vivix/event-statistics",
199
+ packageVersion: "0.1.0",
200
+ actorId: "local",
201
+ access: ["data.workspace"]
202
+ });
203
+ data.register({
204
+ typeId: "statistics.task",
205
+ schemaVersion: 1,
206
+ scopeKinds: ["project"],
207
+ validate: (value) => z4.object({
208
+ taskId: z4.string(),
209
+ attemptId: z4.string(),
210
+ revision: z4.number(),
211
+ outcome: z4.string()
212
+ }).strict().parse(value)
213
+ });
214
+ function accept(task) {
215
+ const state = taskState(task).remote;
216
+ if (state.status === "unknown") return;
217
+ const fact = {
218
+ taskId: task.objectId,
219
+ attemptId: task.objectId,
220
+ revision: state.revision,
221
+ outcome: state.status
222
+ };
223
+ const key = createHash("sha256").update(JSON.stringify(["remote", fact])).digest("hex");
224
+ const object = createHash("sha256").update(task.objectId).digest("hex").slice(0, 32).replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
225
+ data.operation(
226
+ "statistics.accept",
227
+ fact,
228
+ { actor: { kind: "local-user", id: "local" }, requestKey: key },
229
+ (tx) => {
230
+ const old = data.list({ typeId: "statistics.task", taskId: task.objectId, limit: 1 }).items[0];
231
+ if (old && old.data.revision >= fact.revision) return old;
232
+ return old ? tx.update(old.objectId, old.revision, fact) : tx.create("statistics.task", fact, {}, object);
233
+ }
234
+ );
235
+ }
236
+ function synchronize() {
237
+ for (let offset = 0; ; ) {
238
+ const page = studio.data.list({ typeId: "studio.task", limit: 200, offset });
239
+ for (const task of page.items) accept(task);
240
+ if (page.nextOffset === null) break;
241
+ offset = page.nextOffset;
242
+ }
243
+ }
244
+ let stopped = false, handle, timer, wake;
245
+ const work = (async () => {
246
+ let failures = 0;
247
+ while (!stopped) {
248
+ let didSynchronize = false;
249
+ handle = ctx.events.on(
250
+ {
251
+ id: "statistics:current",
252
+ eventNames: [...taskEventNames],
253
+ versions: [1],
254
+ filter: { projectIds: [ctx.scope.id] }
255
+ },
256
+ (event) => {
257
+ if (!stopped && event.correlation.taskId)
258
+ accept(studio.data.get(event.correlation.taskId));
259
+ }
260
+ );
261
+ try {
262
+ await handle.ready;
263
+ if (stopped) return;
264
+ synchronize();
265
+ failures = 0;
266
+ didSynchronize = true;
267
+ const { code } = await handle.closed;
268
+ if (![
269
+ "EVENT_CONNECTION_CLOSED",
270
+ "EVENT_STREAM_DISCONNECTED",
271
+ "EVENT_STREAM_OVERFLOW"
272
+ ].includes(code))
273
+ return;
274
+ } catch (error) {
275
+ if (stopped) return;
276
+ if (![
277
+ "EVENT_CONNECTION_CLOSED",
278
+ "EVENT_STREAM_DISCONNECTED",
279
+ "EVENT_STREAM_OVERFLOW"
280
+ ].includes(error.code || ""))
281
+ throw error;
282
+ } finally {
283
+ await handle.dispose();
284
+ }
285
+ if (!didSynchronize && ++failures > 6)
286
+ throw Error("Event recovery exhausted. Restart this consumer.");
287
+ await new Promise((resolve) => {
288
+ wake = resolve;
289
+ timer = setTimeout(resolve, Math.min(5e3, 250 * 2 ** Math.max(0, failures - 1)));
290
+ });
291
+ }
292
+ })().catch(() => console.error("EVENT_STATISTICS_UNAVAILABLE"));
293
+ ctx.onDispose(async () => {
294
+ stopped = true;
295
+ clearTimeout(timer);
296
+ wake?.();
297
+ await handle?.dispose();
298
+ await work;
299
+ });
300
+ ctx.handle(
301
+ defineOperation({
302
+ id: "statistics.current",
303
+ title: "Read current statistics",
304
+ description: "Read deduplicated task outcomes and current accepted task costs.",
305
+ effects: ["read"],
306
+ input: z4.object({}).strict(),
307
+ output: z4.any()
308
+ }),
309
+ () => {
310
+ const tasks = [], bills = [];
311
+ for (let offset = 0; ; ) {
312
+ const page = data.list({ typeId: "statistics.task", limit: 200, offset });
313
+ tasks.push(...page.items);
314
+ if (page.nextOffset === null) break;
315
+ offset = page.nextOffset;
316
+ }
317
+ for (let offset = 0; ; ) {
318
+ const page = studio.data.list({ typeId: "studio.task", limit: 200, offset });
319
+ bills.push(...page.items.map(taskBillingView));
320
+ if (page.nextOffset === null) break;
321
+ offset = page.nextOffset;
322
+ }
323
+ return { tasks, billing: billingTotals(bills) };
324
+ }
325
+ );
326
+ }
327
+ export {
328
+ Config,
329
+ activate as default,
330
+ dependencies
331
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from 'vivix-studio/sdk/shared';
2
+
3
+ interface BillingView {
4
+ billingRecordId: string;
5
+ taskId: string;
6
+ attemptId: string;
7
+ revision: number;
8
+ status: 'pending' | 'settled' | 'released' | 'unavailable';
9
+ amount: {
10
+ value: string;
11
+ unit: string;
12
+ } | null;
13
+ }
14
+
15
+ declare const Config: z.ZodObject<{
16
+ studioInstance: z.ZodDefault<z.ZodString>;
17
+ dataInstance: z.ZodDefault<z.ZodString>;
18
+ }, z.core.$strict>;
19
+ declare const dependencies: (config: z.infer<typeof Config>) => string[];
20
+ /** Exact decimal sums grouped by explicit unit; missing authority remains unknown. */
21
+ declare function billingTotals(values: readonly BillingView[]): {
22
+ unknown: number;
23
+ units: {
24
+ [k: string]: string;
25
+ };
26
+ };
27
+
28
+ export { Config, billingTotals, dependencies };
package/dist/shared.js ADDED
@@ -0,0 +1,46 @@
1
+ // packages/event-statistics/src/shared.ts
2
+ import { z } from "vivix-studio/sdk/shared";
3
+ var Config = z.object({
4
+ studioInstance: z.string().default("studio"),
5
+ dataInstance: z.string().default("user:data")
6
+ }).strict();
7
+ var dependencies = (config) => [
8
+ config.studioInstance,
9
+ config.dataInstance
10
+ ];
11
+ function billingTotals(values) {
12
+ const latest = /* @__PURE__ */ new Map();
13
+ for (const value of values) {
14
+ const old = latest.get(value.billingRecordId);
15
+ if (!old || value.revision > old.revision) latest.set(value.billingRecordId, value);
16
+ }
17
+ const groups = /* @__PURE__ */ new Map();
18
+ let unknown = 0;
19
+ for (const bill of latest.values()) {
20
+ if (!bill.amount || !bill.amount.unit || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(bill.amount.value)) {
21
+ unknown++;
22
+ continue;
23
+ }
24
+ const [whole, fraction = ""] = bill.amount.value.split("."), scale = fraction.length;
25
+ const old = groups.get(bill.amount.unit) || { value: 0n, scale: 0 };
26
+ const combined = Math.max(scale, old.scale);
27
+ groups.set(bill.amount.unit, {
28
+ value: old.value * 10n ** BigInt(combined - old.scale) + BigInt(whole + fraction) * 10n ** BigInt(combined - scale),
29
+ scale: combined
30
+ });
31
+ }
32
+ return {
33
+ unknown,
34
+ units: Object.fromEntries(
35
+ [...groups].map(([unit, { value, scale }]) => {
36
+ const s = value.toString().padStart(scale + 1, "0");
37
+ return [unit, scale ? s.slice(0, -scale) + "." + s.slice(-scale) : s];
38
+ })
39
+ )
40
+ };
41
+ }
42
+ export {
43
+ Config,
44
+ billingTotals,
45
+ dependencies
46
+ };
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@vivix-labs/event-statistics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-beta",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/",
8
+ "tag": "beta"
9
+ },
10
+ "license": "MIT",
11
+ "files": [
12
+ "dist",
13
+ "studio-plugin.json",
14
+ "skills"
15
+ ],
16
+ "exports": {
17
+ "./studio-plugin.json": "./studio-plugin.json",
18
+ "./server": {
19
+ "types": "./dist/server.d.ts",
20
+ "import": "./dist/server.js"
21
+ },
22
+ "./shared": {
23
+ "types": "./dist/shared.d.ts",
24
+ "import": "./dist/shared.js"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "vivix-studio": ">=2.2.0"
29
+ },
30
+ "scripts": {
31
+ "build": "node ../../scripts/build-product-packages.mjs event-statistics"
32
+ }
6
33
  }
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: event-statistics
3
+ description: Inspect current task outcomes using best-effort Studio notifications and business receipts.
4
+ ---
5
+
6
+ Install the paired event-capable CLI, project-studio and this package. Enable this
7
+ example in a project with `studioInstance` and `dataInstance` bound to the existing
8
+ project studio and workspace data providers. Grant `events.subscribe` explicitly
9
+ when enabling the package; projects do not need notification-specific grants.
10
+ Read `statistics.current` through the existing operation bridge.
11
+
12
+ Every new subscription waits for ready and then paginates current tasks. A task,
13
+ attempt, remote semantic revision and outcome form the business request key. The
14
+ existing DataAPI receipt prevents duplicate effects. Counts come from current task
15
+ snapshots, never the number of notifications. Disconnecting loses intermediate
16
+ notices; restarting synchronizes current facts without generating anything.
17
+
18
+ `statistics.current` reads every page of the project's persisted task costs and
19
+ returns exact decimal totals by unit plus an unknown count. Actual amounts come
20
+ from I4's validated `billing.status` and `billing.credits`, not event payloads.
21
+ Released zero and settled zero stay distinct from unknown costs. Local revisions
22
+ order accepted snapshots; they do not order different confirmed upstream amounts.
23
+
24
+ Automatic cost recovery uses one persisted 18-second window per original task,
25
+ with at most five serial attempts targeted at 0, 2, 4, 10, and 15 seconds. Confirmation,
26
+ exhaustion, or the deadline stops recovery. Unknown costs can remain unknown.
27
+ `tasks.queryOriginal` retains manual reading without restarting that window.
28
+ Refunds and adjustments are outside this example's contract. Developer fixtures
29
+ and package checks do not prove real settlement or production activation.
@@ -0,0 +1,11 @@
1
+ {
2
+ "manifestVersion": "0.2",
3
+ "id": "event-statistics",
4
+ "requirements": {
5
+ "studioPluginApi": "^0.2.2"
6
+ },
7
+ "requestedAccess": ["events.subscribe"],
8
+ "resources": {
9
+ "skills": ["skills/SKILL.md"]
10
+ }
11
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.