@oxygen-agent/cli 1.922.14 → 1.948.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/admin-primary-providers-render.d.ts +18 -0
- package/dist/admin-primary-providers-render.js +371 -0
- package/dist/command-manifest.js +30 -2
- package/dist/functions-commands.d.ts +6 -0
- package/dist/functions-commands.js +56 -0
- package/dist/help.js +1 -0
- package/dist/http-client.d.ts +4 -0
- package/dist/http-client.js +49 -2
- package/dist/index.js +515 -92
- package/dist/ugc-commands.d.ts +6 -0
- package/dist/ugc-commands.js +1089 -0
- package/dist/visual-commands.d.ts +6 -0
- package/dist/visual-commands.js +57 -0
- package/dist/visual-render-wait.d.ts +3 -0
- package/dist/visual-render-wait.js +56 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +48 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +92 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -13
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +3 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/index.js +10 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
- package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/knowledge-constants.js +3 -2
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +1 -1
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +185 -121
- package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +5 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
- package/node_modules/@oxygen/shared/dist/provider-balance-signal.d.ts +113 -0
- package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +158 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +24 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +54 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +133 -0
- package/node_modules/@oxygen/shared/dist/ugc.js +2 -0
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +33 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/visual-render.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/visual-render.js +55 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +126 -0
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product-analytics core — the TRANSPORT-FREE half of PostHog emission.
|
|
3
|
+
*
|
|
4
|
+
* Two runtimes emit product events: the web/API runtime (`apps/web/src/lib/
|
|
5
|
+
* product-analytics.ts`, `posthog-node` on Vercel) and the Fly worker
|
|
6
|
+
* (`apps/worker/src/product-analytics.ts`, `posthog-node` batching). What they
|
|
7
|
+
* must agree on lives here, once: property sanitization, the distinct-id
|
|
8
|
+
* chain, the person-profile posture, org grouping, and the runtime dimensions
|
|
9
|
+
* every event carries. Two copies of that logic drifted in the past — the
|
|
10
|
+
* worker would have minted a throwaway person per request while the web side
|
|
11
|
+
* did not — and PostHog cannot join what the emitters disagree about.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NO `posthog-node`, no fetch, no I/O: `packages/shared` is
|
|
14
|
+
* vendored into the published `@oxygen-agent/cli` tarball, so every runtime
|
|
15
|
+
* dependency added here lands on every customer's machine
|
|
16
|
+
* (`scripts/cli-package-dependencies.mjs`, the langfuse-class guard). The
|
|
17
|
+
* capture message is a plain object structurally compatible with
|
|
18
|
+
* `posthog-node`'s `EventMessage`; each runtime owns its own client.
|
|
19
|
+
*/
|
|
20
|
+
export const PRODUCT_ANALYTICS_MAX_PROPERTY_KEY_LENGTH = 80;
|
|
21
|
+
export const PRODUCT_ANALYTICS_MAX_PROPERTY_STRING_LENGTH = 500;
|
|
22
|
+
/**
|
|
23
|
+
* Byte ceiling for a structured (object/array) property value.
|
|
24
|
+
*
|
|
25
|
+
* Structured values pass through UNTOUCHED — see `sanitizeStructuredValue` — so
|
|
26
|
+
* this is the only thing standing between a careless caller and an unbounded
|
|
27
|
+
* ingest payload. Set well above `MCP_PAYLOAD_LIMITS.maxSerializedBytes` (8 KiB,
|
|
28
|
+
* in apps/web `analytics/mcp-analytics.ts`) so it can never re-cut a payload
|
|
29
|
+
* that module already bounded and marked; it exists for the callers that bound
|
|
30
|
+
* nothing.
|
|
31
|
+
*/
|
|
32
|
+
export const PRODUCT_ANALYTICS_MAX_STRUCTURED_PROPERTY_BYTES = 32_768;
|
|
33
|
+
export function sanitizeProductAnalyticsProperties(properties) {
|
|
34
|
+
const sanitized = {};
|
|
35
|
+
for (const [rawKey, rawValue] of Object.entries(properties)) {
|
|
36
|
+
const key = sanitizePropertyKey(rawKey);
|
|
37
|
+
if (!key || rawValue === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
const value = sanitizePropertyValue(rawValue);
|
|
40
|
+
if (value !== undefined)
|
|
41
|
+
sanitized[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return sanitized;
|
|
44
|
+
}
|
|
45
|
+
function sanitizePropertyKey(key) {
|
|
46
|
+
const normalized = key
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/[^A-Za-z0-9_.$-]/g, "_")
|
|
49
|
+
.slice(0, PRODUCT_ANALYTICS_MAX_PROPERTY_KEY_LENGTH);
|
|
50
|
+
return normalized || null;
|
|
51
|
+
}
|
|
52
|
+
function sanitizePropertyValue(value) {
|
|
53
|
+
if (value === null || value === undefined)
|
|
54
|
+
return value;
|
|
55
|
+
if (typeof value === "boolean")
|
|
56
|
+
return value;
|
|
57
|
+
if (typeof value === "number")
|
|
58
|
+
return Number.isFinite(value) ? value : null;
|
|
59
|
+
if (typeof value === "string")
|
|
60
|
+
return value.slice(0, PRODUCT_ANALYTICS_MAX_PROPERTY_STRING_LENGTH);
|
|
61
|
+
return sanitizeStructuredValue(value);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Objects and arrays pass through UNTOUCHED, or not at all.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately not walked, re-keyed, `String()`d, or cut at the string ceiling:
|
|
67
|
+
* the callers that send structured values (apps/web `analytics/mcp-analytics.ts`)
|
|
68
|
+
* have already redacted and bounded them, and a second pass here would either
|
|
69
|
+
* corrupt a payload into invalid JSON — which is exactly what an old
|
|
70
|
+
* `.slice(0, 500)` did to any array — or silently undo the marked truncation
|
|
71
|
+
* the first pass recorded.
|
|
72
|
+
*
|
|
73
|
+
* The only checks are the two that protect the pipe: it must be JSON-encodable
|
|
74
|
+
* (a cycle or a BigInt would otherwise throw inside the SDK, and `JSON.stringify`
|
|
75
|
+
* itself throws HERE, upstream of the emitter's try), and it must be under the
|
|
76
|
+
* byte ceiling. Failing either drops the one property, never the event.
|
|
77
|
+
*/
|
|
78
|
+
function sanitizeStructuredValue(value) {
|
|
79
|
+
try {
|
|
80
|
+
const serialized = JSON.stringify(value);
|
|
81
|
+
if (typeof serialized !== "string")
|
|
82
|
+
return undefined;
|
|
83
|
+
if (Buffer.byteLength(serialized, "utf8") > PRODUCT_ANALYTICS_MAX_STRUCTURED_PROPERTY_BYTES) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function normalizeProductAnalyticsIdentifier(value) {
|
|
93
|
+
if (typeof value === "string" && value.trim()) {
|
|
94
|
+
return value.trim().slice(0, PRODUCT_ANALYTICS_MAX_PROPERTY_STRING_LENGTH);
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
97
|
+
return String(value);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the capture message every runtime sends. Returns null when no distinct
|
|
102
|
+
* id can be derived — an event with no subject is dropped, never invented.
|
|
103
|
+
*/
|
|
104
|
+
export function buildProductAnalyticsCaptureMessage(input, context) {
|
|
105
|
+
const organizationId = normalizeProductAnalyticsIdentifier(input.organizationId);
|
|
106
|
+
const distinctIdSource = [
|
|
107
|
+
{ value: input.distinctId, identifiesPerson: true },
|
|
108
|
+
{ value: input.userId, identifiesPerson: true },
|
|
109
|
+
// Namespaced: a bare org UUID is indistinguishable from a human UUID at
|
|
110
|
+
// query time, so an unprefixed org fallback silently pollutes person-level
|
|
111
|
+
// counts (and any "users who…" cohort) with workspace rows. The `groups`
|
|
112
|
+
// attachment below keeps the RAW id — PostHog resolves group keys by exact
|
|
113
|
+
// match against the org id the rest of the product uses.
|
|
114
|
+
{ value: organizationId ? `org:${organizationId}` : null, identifiesPerson: false },
|
|
115
|
+
{ value: input.properties?.trace_id, identifiesPerson: false },
|
|
116
|
+
{ value: input.properties?.request_id, identifiesPerson: false },
|
|
117
|
+
].find((candidate) => candidate.value !== null && candidate.value !== undefined);
|
|
118
|
+
const distinctId = normalizeProductAnalyticsIdentifier(distinctIdSource?.value);
|
|
119
|
+
if (!distinctId)
|
|
120
|
+
return null;
|
|
121
|
+
const properties = sanitizeProductAnalyticsProperties({
|
|
122
|
+
product_analytics_environment: context.environment,
|
|
123
|
+
oxygen_version: context.oxygenVersion,
|
|
124
|
+
...context.runtimeProperties,
|
|
125
|
+
organization_id: organizationId,
|
|
126
|
+
user_id: normalizeProductAnalyticsIdentifier(input.userId),
|
|
127
|
+
...input.properties,
|
|
128
|
+
// Person profiles are a per-event decision on backend SDKs, not an init
|
|
129
|
+
// option. `posthog-node`'s client extends `PostHogCoreStateless`, which has
|
|
130
|
+
// no person-mode state at all — only the browser's stateful `PostHogCore`
|
|
131
|
+
// reads `personProfiles`. A `personProfiles: "identified_only"` passed to the
|
|
132
|
+
// constructor typechecks and is then read by nothing, so every event would
|
|
133
|
+
// get a person profile regardless.
|
|
134
|
+
//
|
|
135
|
+
// Without this, the `trace_id`/`request_id` tail of the distinct-id chain
|
|
136
|
+
// mints one throwaway person per request. Set last so a caller-supplied
|
|
137
|
+
// property can never flip the posture.
|
|
138
|
+
//
|
|
139
|
+
// `|| organizationId` is load-bearing, not defensive: PostHog cannot run
|
|
140
|
+
// group analytics on an anonymous event, and `@posthog/core`'s own
|
|
141
|
+
// `_hasPersonProcessing()` is `isIdentified || hasGroups || enabled` — the
|
|
142
|
+
// SDK forces person processing ON whenever `$groups` is attached. Emitting
|
|
143
|
+
// the inverse pairing would silently zero every org-grouped trend, funnel
|
|
144
|
+
// and retention on exactly the two highest-volume events
|
|
145
|
+
// (`cli_command_run`, `$mcp_tool_call`), because org-API-key traffic has no
|
|
146
|
+
// `userId` and falls through to the org-id candidate. The cost this whole
|
|
147
|
+
// switch targets is the per-REQUEST trace/request-id tail; one profile per
|
|
148
|
+
// ORG is cheap and is where the group data lives.
|
|
149
|
+
$process_person_profile: (distinctIdSource?.identifiesPerson ?? false) || Boolean(organizationId),
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
distinctId,
|
|
153
|
+
event: input.event,
|
|
154
|
+
properties,
|
|
155
|
+
...(organizationId ? { groups: { organization: organizationId } } : {}),
|
|
156
|
+
...(input.uuid ? { uuid: input.uuid } : {}),
|
|
157
|
+
...(input.timestamp ? { timestamp: input.timestamp } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PROD / MAIN / DEV / LOCAL dimension stamped on every product event as
|
|
3
|
+
* `product_analytics_environment`. One resolver for both emitting runtimes:
|
|
4
|
+
*
|
|
5
|
+
* - explicit pins win (`POSTHOG_ENVIRONMENT`, its `NEXT_PUBLIC_` twin, and the
|
|
6
|
+
* Sentry environment vars the web app already sets);
|
|
7
|
+
* - the web/API runtime has `VERCEL_ENV` (+ the branch for MAIN-preview);
|
|
8
|
+
* - the Fly worker has NO `VERCEL_ENV` and `NODE_ENV=production` on BOTH apps,
|
|
9
|
+
* so — mirroring `apps/worker/src/worker-env.ts` and the Langfuse resolver —
|
|
10
|
+
* the Fly-injected app name is the ground truth (`-dev` marks the dev app; a
|
|
11
|
+
* cross-env Doppler drift cannot fake it), then `FLY_ENVIRONMENT`;
|
|
12
|
+
* - everything else is LOCAL. `NODE_ENV=production` is deliberately NOT read as
|
|
13
|
+
* PROD: it is the default for any built Node process, a local one included.
|
|
14
|
+
*/
|
|
15
|
+
export type ProductAnalyticsEnvironment = "PROD" | "MAIN" | "DEV" | "LOCAL";
|
|
16
|
+
type EnvSource = Record<string, string | undefined>;
|
|
17
|
+
export declare function resolveProductAnalyticsEnvironmentFromEnv(env?: EnvSource): ProductAnalyticsEnvironment;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function normalizeProductAnalyticsEnvironment(value) {
|
|
2
|
+
if (!value)
|
|
3
|
+
return null;
|
|
4
|
+
switch (value.trim().toUpperCase()) {
|
|
5
|
+
case "PROD":
|
|
6
|
+
case "PRODUCTION":
|
|
7
|
+
return "PROD";
|
|
8
|
+
case "MAIN":
|
|
9
|
+
return "MAIN";
|
|
10
|
+
case "DEV":
|
|
11
|
+
case "DEVELOPMENT":
|
|
12
|
+
case "PREVIEW":
|
|
13
|
+
return "DEV";
|
|
14
|
+
case "LOCAL":
|
|
15
|
+
return "LOCAL";
|
|
16
|
+
default:
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function resolveProductAnalyticsEnvironmentFromEnv(env = process.env) {
|
|
21
|
+
const explicit = normalizeProductAnalyticsEnvironment(env.POSTHOG_ENVIRONMENT
|
|
22
|
+
?? env.NEXT_PUBLIC_POSTHOG_ENVIRONMENT
|
|
23
|
+
?? env.SENTRY_ENVIRONMENT
|
|
24
|
+
?? env.NEXT_PUBLIC_SENTRY_ENVIRONMENT);
|
|
25
|
+
if (explicit)
|
|
26
|
+
return explicit;
|
|
27
|
+
const vercelEnv = env.VERCEL_ENV ?? env.NEXT_PUBLIC_VERCEL_ENV;
|
|
28
|
+
if (vercelEnv === "production")
|
|
29
|
+
return "PROD";
|
|
30
|
+
const branch = env.VERCEL_GIT_COMMIT_REF ?? env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF;
|
|
31
|
+
if (vercelEnv === "preview" && branch === "main")
|
|
32
|
+
return "MAIN";
|
|
33
|
+
if (vercelEnv === "preview")
|
|
34
|
+
return "DEV";
|
|
35
|
+
if (vercelEnv === "development")
|
|
36
|
+
return "LOCAL";
|
|
37
|
+
const flyApp = (env.FLY_APP_NAME ?? env.FLY_APP)?.trim();
|
|
38
|
+
if (flyApp)
|
|
39
|
+
return flyApp.includes("-dev") ? "DEV" : "PROD";
|
|
40
|
+
const flyEnvironment = env.FLY_ENVIRONMENT?.trim().toLowerCase();
|
|
41
|
+
if (flyEnvironment === "production")
|
|
42
|
+
return "PROD";
|
|
43
|
+
if (flyEnvironment)
|
|
44
|
+
return "DEV";
|
|
45
|
+
return "LOCAL";
|
|
46
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every product-analytics event name OXYGEN emits, in one place — for BOTH
|
|
3
|
+
* emitting runtimes. The web/API runtime re-exports this from
|
|
4
|
+
* `apps/web/src/lib/analytics/event-names.ts` (which also owns PostHog's
|
|
5
|
+
* reserved `$mcp_*` vocabulary and the KPI-ledger translation); the Fly worker
|
|
6
|
+
* imports it directly.
|
|
7
|
+
*
|
|
8
|
+
* An event name is a contract shared by three things that cannot see each
|
|
9
|
+
* other: the emitter, its test, and the PostHog insight/funnel built on top. A
|
|
10
|
+
* literal typed at each call site drifts silently — the rename lands in the
|
|
11
|
+
* code, the dashboard keeps querying the old string, and the funnel reads zero
|
|
12
|
+
* without erroring. Import from here instead of typing the string.
|
|
13
|
+
*
|
|
14
|
+
* Names are unprefixed `subject_verb` snake_case; the web test pins the exact
|
|
15
|
+
* set, so adding one is a deliberate edit there too.
|
|
16
|
+
*/
|
|
17
|
+
export declare const PRODUCT_EVENTS: {
|
|
18
|
+
/** Acquisition. */
|
|
19
|
+
readonly USER_SIGNED_UP: "user_signed_up";
|
|
20
|
+
readonly WORKSPACE_CREATED: "workspace_created";
|
|
21
|
+
/** Activation. */
|
|
22
|
+
readonly ONBOARDING_COMPLETED: "onboarding_completed";
|
|
23
|
+
readonly ONBOARDING_RESET: "onboarding_reset";
|
|
24
|
+
readonly ACTIVATION_PATH_SELECTED: "activation_path_selected";
|
|
25
|
+
readonly AGENT_CONNECTED: "agent_connected";
|
|
26
|
+
readonly FIRST_RECORD_CREATED: "first_record_created";
|
|
27
|
+
/**
|
|
28
|
+
* The prescribed first play (inbound-led outbound), walked on ANY surface.
|
|
29
|
+
*
|
|
30
|
+
* These describe progress through a play, not a lifecycle moment — a founder
|
|
31
|
+
* who arms capture from the CLI and drafts the sequence in the browser
|
|
32
|
+
* produces one continuous series. Which is exactly why they are emitted from
|
|
33
|
+
* one watermark-guarded helper (apps/web `lib/activation/analytics.ts`)
|
|
34
|
+
* rather than from each surface: the same completed-step count read twice
|
|
35
|
+
* must emit once.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately ABSENT from the web's KPI_TYPE_TO_EVENT. The headline
|
|
38
|
+
* activation KPI is CLI/MCP-sourced by design; wiring a web-observable play
|
|
39
|
+
* step into that ledger would silently redefine what the company reports as
|
|
40
|
+
* activation.
|
|
41
|
+
*/
|
|
42
|
+
readonly FIRST_PLAY_STARTED: "first_play_started";
|
|
43
|
+
readonly FIRST_PLAY_STEP_COMPLETED: "first_play_step_completed";
|
|
44
|
+
readonly FIRST_PLAY_COMPLETED: "first_play_completed";
|
|
45
|
+
readonly FIRST_PLAY_SKIPPED: "first_play_skipped";
|
|
46
|
+
/**
|
|
47
|
+
* Usage — what was ASKED for. `mcp_tool_called` used to sit here as the
|
|
48
|
+
* second highest-volume event; it was REPLACED (not joined) by the reserved
|
|
49
|
+
* `$mcp_tool_call` in the web's MCP_ANALYTICS_EVENTS — see that block for why
|
|
50
|
+
* both cannot be emitted.
|
|
51
|
+
*/
|
|
52
|
+
readonly CLI_COMMAND_RUN: "cli_command_run";
|
|
53
|
+
/**
|
|
54
|
+
* Outcomes — what actually HAPPENED, emitted by the Fly worker when a durable
|
|
55
|
+
* unit of work reaches a terminal state. `cli_command_run` and `$mcp_tool_call`
|
|
56
|
+
* see a run being requested; nothing on the web runtime ever sees it finish,
|
|
57
|
+
* because workflows, column runs, ingestions, agent runs and sequence steps
|
|
58
|
+
* complete on Fly minutes later. Without these, every "did it work" question
|
|
59
|
+
* (success rate, duration, credits per run, replies per enrollment) was
|
|
60
|
+
* unanswerable in PostHog and biased toward optimism — sweeps, repair passes
|
|
61
|
+
* and lease expiries settle runs invisibly.
|
|
62
|
+
*
|
|
63
|
+
* One event per terminal transition, `uuid` = the run/outbox id so a
|
|
64
|
+
* re-read after a worker restart dedupes, `timestamp` = when it settled.
|
|
65
|
+
* Properties are ids, enums and numbers only — never row values, prompts,
|
|
66
|
+
* or lead names (ADR 0021: PostHog stays sanitized).
|
|
67
|
+
*/
|
|
68
|
+
readonly WORKFLOW_RUN_SETTLED: "workflow_run_settled";
|
|
69
|
+
readonly TABLE_ACTION_RUN_SETTLED: "table_action_run_settled";
|
|
70
|
+
readonly TABLE_INGESTION_RUN_SETTLED: "table_ingestion_run_settled";
|
|
71
|
+
readonly AGENT_RUN_SETTLED: "agent_run_settled";
|
|
72
|
+
readonly SEQUENCE_ENROLLMENT_SETTLED: "sequence_enrollment_settled";
|
|
73
|
+
/** Revenue lifecycle. */
|
|
74
|
+
readonly TRIAL_STARTED: "trial_started";
|
|
75
|
+
readonly SUBSCRIPTION_ACTIVATED: "subscription_activated";
|
|
76
|
+
readonly SUBSCRIPTION_CHURNED: "subscription_churned";
|
|
77
|
+
readonly TRIAL_CANCEL_SCHEDULED: "trial_cancel_scheduled";
|
|
78
|
+
readonly TRIAL_CANCEL_RESUMED: "trial_cancel_resumed";
|
|
79
|
+
readonly TRIAL_ENDED_UNCONVERTED: "trial_ended_unconverted";
|
|
80
|
+
/** Pipeline heartbeat: proves ingestion is alive when the product is quiet. */
|
|
81
|
+
readonly POSTHOG_CANARY: "posthog_canary";
|
|
82
|
+
};
|
|
83
|
+
export type ProductEventName = (typeof PRODUCT_EVENTS)[keyof typeof PRODUCT_EVENTS];
|
|
84
|
+
/** Every name, for drift checks and dashboard provisioning. */
|
|
85
|
+
export declare const PRODUCT_EVENT_NAMES: readonly ProductEventName[];
|
|
86
|
+
/**
|
|
87
|
+
* The outcome events the worker emits, as a typed subset — so the worker's
|
|
88
|
+
* emitter cannot be handed a lifecycle event that only the control plane has
|
|
89
|
+
* the facts for.
|
|
90
|
+
*/
|
|
91
|
+
export declare const WORKER_RUN_OUTCOME_EVENTS: readonly ["workflow_run_settled", "table_action_run_settled", "table_ingestion_run_settled", "agent_run_settled", "sequence_enrollment_settled"];
|
|
92
|
+
export type WorkerRunOutcomeEventName = (typeof WORKER_RUN_OUTCOME_EVENTS)[number];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every product-analytics event name OXYGEN emits, in one place — for BOTH
|
|
3
|
+
* emitting runtimes. The web/API runtime re-exports this from
|
|
4
|
+
* `apps/web/src/lib/analytics/event-names.ts` (which also owns PostHog's
|
|
5
|
+
* reserved `$mcp_*` vocabulary and the KPI-ledger translation); the Fly worker
|
|
6
|
+
* imports it directly.
|
|
7
|
+
*
|
|
8
|
+
* An event name is a contract shared by three things that cannot see each
|
|
9
|
+
* other: the emitter, its test, and the PostHog insight/funnel built on top. A
|
|
10
|
+
* literal typed at each call site drifts silently — the rename lands in the
|
|
11
|
+
* code, the dashboard keeps querying the old string, and the funnel reads zero
|
|
12
|
+
* without erroring. Import from here instead of typing the string.
|
|
13
|
+
*
|
|
14
|
+
* Names are unprefixed `subject_verb` snake_case; the web test pins the exact
|
|
15
|
+
* set, so adding one is a deliberate edit there too.
|
|
16
|
+
*/
|
|
17
|
+
export const PRODUCT_EVENTS = {
|
|
18
|
+
/** Acquisition. */
|
|
19
|
+
USER_SIGNED_UP: "user_signed_up",
|
|
20
|
+
WORKSPACE_CREATED: "workspace_created",
|
|
21
|
+
/** Activation. */
|
|
22
|
+
ONBOARDING_COMPLETED: "onboarding_completed",
|
|
23
|
+
ONBOARDING_RESET: "onboarding_reset",
|
|
24
|
+
ACTIVATION_PATH_SELECTED: "activation_path_selected",
|
|
25
|
+
AGENT_CONNECTED: "agent_connected",
|
|
26
|
+
FIRST_RECORD_CREATED: "first_record_created",
|
|
27
|
+
/**
|
|
28
|
+
* The prescribed first play (inbound-led outbound), walked on ANY surface.
|
|
29
|
+
*
|
|
30
|
+
* These describe progress through a play, not a lifecycle moment — a founder
|
|
31
|
+
* who arms capture from the CLI and drafts the sequence in the browser
|
|
32
|
+
* produces one continuous series. Which is exactly why they are emitted from
|
|
33
|
+
* one watermark-guarded helper (apps/web `lib/activation/analytics.ts`)
|
|
34
|
+
* rather than from each surface: the same completed-step count read twice
|
|
35
|
+
* must emit once.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately ABSENT from the web's KPI_TYPE_TO_EVENT. The headline
|
|
38
|
+
* activation KPI is CLI/MCP-sourced by design; wiring a web-observable play
|
|
39
|
+
* step into that ledger would silently redefine what the company reports as
|
|
40
|
+
* activation.
|
|
41
|
+
*/
|
|
42
|
+
FIRST_PLAY_STARTED: "first_play_started",
|
|
43
|
+
FIRST_PLAY_STEP_COMPLETED: "first_play_step_completed",
|
|
44
|
+
FIRST_PLAY_COMPLETED: "first_play_completed",
|
|
45
|
+
FIRST_PLAY_SKIPPED: "first_play_skipped",
|
|
46
|
+
/**
|
|
47
|
+
* Usage — what was ASKED for. `mcp_tool_called` used to sit here as the
|
|
48
|
+
* second highest-volume event; it was REPLACED (not joined) by the reserved
|
|
49
|
+
* `$mcp_tool_call` in the web's MCP_ANALYTICS_EVENTS — see that block for why
|
|
50
|
+
* both cannot be emitted.
|
|
51
|
+
*/
|
|
52
|
+
CLI_COMMAND_RUN: "cli_command_run",
|
|
53
|
+
/**
|
|
54
|
+
* Outcomes — what actually HAPPENED, emitted by the Fly worker when a durable
|
|
55
|
+
* unit of work reaches a terminal state. `cli_command_run` and `$mcp_tool_call`
|
|
56
|
+
* see a run being requested; nothing on the web runtime ever sees it finish,
|
|
57
|
+
* because workflows, column runs, ingestions, agent runs and sequence steps
|
|
58
|
+
* complete on Fly minutes later. Without these, every "did it work" question
|
|
59
|
+
* (success rate, duration, credits per run, replies per enrollment) was
|
|
60
|
+
* unanswerable in PostHog and biased toward optimism — sweeps, repair passes
|
|
61
|
+
* and lease expiries settle runs invisibly.
|
|
62
|
+
*
|
|
63
|
+
* One event per terminal transition, `uuid` = the run/outbox id so a
|
|
64
|
+
* re-read after a worker restart dedupes, `timestamp` = when it settled.
|
|
65
|
+
* Properties are ids, enums and numbers only — never row values, prompts,
|
|
66
|
+
* or lead names (ADR 0021: PostHog stays sanitized).
|
|
67
|
+
*/
|
|
68
|
+
WORKFLOW_RUN_SETTLED: "workflow_run_settled",
|
|
69
|
+
TABLE_ACTION_RUN_SETTLED: "table_action_run_settled",
|
|
70
|
+
TABLE_INGESTION_RUN_SETTLED: "table_ingestion_run_settled",
|
|
71
|
+
AGENT_RUN_SETTLED: "agent_run_settled",
|
|
72
|
+
SEQUENCE_ENROLLMENT_SETTLED: "sequence_enrollment_settled",
|
|
73
|
+
/** Revenue lifecycle. */
|
|
74
|
+
TRIAL_STARTED: "trial_started",
|
|
75
|
+
SUBSCRIPTION_ACTIVATED: "subscription_activated",
|
|
76
|
+
SUBSCRIPTION_CHURNED: "subscription_churned",
|
|
77
|
+
TRIAL_CANCEL_SCHEDULED: "trial_cancel_scheduled",
|
|
78
|
+
TRIAL_CANCEL_RESUMED: "trial_cancel_resumed",
|
|
79
|
+
TRIAL_ENDED_UNCONVERTED: "trial_ended_unconverted",
|
|
80
|
+
/** Pipeline heartbeat: proves ingestion is alive when the product is quiet. */
|
|
81
|
+
POSTHOG_CANARY: "posthog_canary",
|
|
82
|
+
};
|
|
83
|
+
/** Every name, for drift checks and dashboard provisioning. */
|
|
84
|
+
export const PRODUCT_EVENT_NAMES = Object.freeze(Object.values(PRODUCT_EVENTS));
|
|
85
|
+
/**
|
|
86
|
+
* The outcome events the worker emits, as a typed subset — so the worker's
|
|
87
|
+
* emitter cannot be handed a lifecycle event that only the control plane has
|
|
88
|
+
* the facts for.
|
|
89
|
+
*/
|
|
90
|
+
export const WORKER_RUN_OUTCOME_EVENTS = Object.freeze([
|
|
91
|
+
PRODUCT_EVENTS.WORKFLOW_RUN_SETTLED,
|
|
92
|
+
PRODUCT_EVENTS.TABLE_ACTION_RUN_SETTLED,
|
|
93
|
+
PRODUCT_EVENTS.TABLE_INGESTION_RUN_SETTLED,
|
|
94
|
+
PRODUCT_EVENTS.AGENT_RUN_SETTLED,
|
|
95
|
+
PRODUCT_EVENTS.SEQUENCE_ENROLLMENT_SETTLED,
|
|
96
|
+
]);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed-provider BALANCE signal — one contract shared by the two producers
|
|
3
|
+
* that can observe it and the one Axiom monitor that alerts on it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Until now the only way OXYGEN learned that one of its POOLED
|
|
6
|
+
* provider accounts had run out of money was a customer's call being refused.
|
|
7
|
+
* `provider.managed_credits_exhausted` fires at that moment — after the failure,
|
|
8
|
+
* never before it. Plain T-104 (Mentcape, 2026-08-24 and again 2026-08-31) is what
|
|
9
|
+
* that costs: `serper.places`, `serper.maps` and `parallel.search` refused live
|
|
10
|
+
* calls for a workspace holding 75,347 Oxygen credits, because OUR balance with
|
|
11
|
+
* those vendors was empty. Prod Axiom over the 16 days to 2026-09-05 shows the same
|
|
12
|
+
* refusal reaching >=6 orgs on serper, >=3 on parallel and >=5 on exa.
|
|
13
|
+
*
|
|
14
|
+
* The balance snapshot cron already read most of those balances every two hours and
|
|
15
|
+
* wrote them to `provider_balance_snapshots`. It emitted no per-provider log line at
|
|
16
|
+
* all, so a balance sliding toward zero was visible only to a human who opened
|
|
17
|
+
* /admin/costs. This module is the missing signal: one structured line per managed
|
|
18
|
+
* provider per snapshot, carrying the number AND the verdict.
|
|
19
|
+
*
|
|
20
|
+
* TWO PRODUCERS, ONE VOCABULARY.
|
|
21
|
+
*
|
|
22
|
+
* - `provider_balance.snapshot` — the cron, for every provider whose balance we can
|
|
23
|
+
* actually read. Proactive: it fires while there is still money left.
|
|
24
|
+
* - `provider_balance.exhausted` — the tool runner, when a managed account actually
|
|
25
|
+
* refuses a paid call. That is the ONLY balance evidence available for a provider
|
|
26
|
+
* with no usable balance API, which is exactly the T-104 three (see
|
|
27
|
+
* PROVIDERS_WITHOUT_BALANCE_API in @oxygen/providers). Without it the new monitor
|
|
28
|
+
* would be structurally blind to the providers that caused the incident.
|
|
29
|
+
*
|
|
30
|
+
* Both carry the same `status` vocabulary so ONE monitor query covers both, and both
|
|
31
|
+
* put `status` and `provider` in flat fields because those are the two dimensions the
|
|
32
|
+
* monitor filters and groups on. Every other field rides in the `worker_fields` map
|
|
33
|
+
* at zero column cost — see AXIOM_STABLE_FIELDS in ./axiom-field-budget.ts, which is
|
|
34
|
+
* load-bearing in both directions.
|
|
35
|
+
*/
|
|
36
|
+
/** The proactive line, emitted per managed provider by the balance-snapshot cron. */
|
|
37
|
+
export declare const PROVIDER_BALANCE_SNAPSHOT_MSG = "provider_balance.snapshot";
|
|
38
|
+
/**
|
|
39
|
+
* The reactive line, emitted by the tool runner when a managed account refuses a paid
|
|
40
|
+
* call. Deliberately a SEPARATE msg from `provider.managed_credits_exhausted`: that
|
|
41
|
+
* one is the refusal event (a customer call failed), this one is the balance fact (our
|
|
42
|
+
* account is empty). The refusal monitor counts the former; conflating them would make
|
|
43
|
+
* one query mean two different things.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PROVIDER_BALANCE_EXHAUSTED_MSG = "provider_balance.exhausted";
|
|
46
|
+
/**
|
|
47
|
+
* The verdict on one managed balance.
|
|
48
|
+
*
|
|
49
|
+
* `unknown` is not a synonym for `ok`, and the distinction is the whole point: the
|
|
50
|
+
* pre-existing snapshot recorded status `ok` with `balance_remaining = null` for
|
|
51
|
+
* blitzapi and contactout on all 353 rows of the last 30 days, and would have
|
|
52
|
+
* recorded `ok` at a balance of 0 too. "We asked and got no number" must never read
|
|
53
|
+
* as "there is money".
|
|
54
|
+
*/
|
|
55
|
+
export type ProviderBalanceStatus = "ok" | "low" | "exhausted" | "unknown" | "not_monitored" | "auth_error" | "rate_limited" | "error";
|
|
56
|
+
/** The statuses the P1 balance monitor alerts on. Anything else is informational. */
|
|
57
|
+
export declare const ALERTING_PROVIDER_BALANCE_STATUSES: readonly ProviderBalanceStatus[];
|
|
58
|
+
/**
|
|
59
|
+
* Default low-balance floor, in the provider's OWN unit (almost always provider
|
|
60
|
+
* credits). 1,000 is not a round number chosen for looking tidy — it is roughly
|
|
61
|
+
* three to eight days of measured burn for the credit providers OXYGEN funds,
|
|
62
|
+
* taken from `provider_balance_snapshots` in the prod control DB on 2026-09-07
|
|
63
|
+
* over the preceding 30 days:
|
|
64
|
+
*
|
|
65
|
+
* bettercontact 10,130 -> 364 (~325/day) ~3 days of head-room at 1,000
|
|
66
|
+
* leadmagic 7,702 -> 903 (~227/day) ~4 days
|
|
67
|
+
* millionverifier 41,357 -> 37,695 (~122/day) ~8 days
|
|
68
|
+
* ai_ark 10,000 -> 9,813 (~6/day) months
|
|
69
|
+
*
|
|
70
|
+
* A floor is a claim about how the account behaved when it was measured, nothing
|
|
71
|
+
* more. Re-measure it rather than nudging it when it turns out to be noisy — the
|
|
72
|
+
* monitor changelog exists for exactly that conversation.
|
|
73
|
+
*/
|
|
74
|
+
export declare const DEFAULT_MANAGED_BALANCE_FLOOR = 1000;
|
|
75
|
+
/**
|
|
76
|
+
* Per-provider floors, and the providers that are deliberately NOT balance-monitored
|
|
77
|
+
* (`null`). Only entries that the default gets wrong are listed; everything else
|
|
78
|
+
* inherits DEFAULT_MANAGED_BALANCE_FLOOR.
|
|
79
|
+
*/
|
|
80
|
+
export declare const MANAGED_BALANCE_FLOORS: Readonly<Record<string, number | null>>;
|
|
81
|
+
/** `OXYGEN_MANAGED_BALANCE_FLOOR_FIRECRAWL`, `..._AI_ARK`, ... */
|
|
82
|
+
export declare function managedBalanceFloorEnvVar(provider: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* The floor in force for one provider. `null` means "do not judge this balance".
|
|
85
|
+
*
|
|
86
|
+
* The env override is the operator's escape hatch between deploys: a top-up that
|
|
87
|
+
* changes the plan size, or a provider that turns out to be noisy at the default,
|
|
88
|
+
* is one Doppler value away from being right. `off`/`none`/`disabled` switches the
|
|
89
|
+
* provider off the monitor entirely; an unparseable or negative value is IGNORED
|
|
90
|
+
* rather than obeyed, because a typo must not silently disarm an alert.
|
|
91
|
+
*/
|
|
92
|
+
export declare function managedBalanceFloor(provider: string, env?: Record<string, string | undefined>): number | null;
|
|
93
|
+
export type ProviderBalanceVerdict = {
|
|
94
|
+
status: ProviderBalanceStatus;
|
|
95
|
+
/** The single boolean the monitor's description tells a responder to read. */
|
|
96
|
+
balanceLow: boolean;
|
|
97
|
+
/** The floor actually applied, after the env override. `null` = not monitored. */
|
|
98
|
+
floor: number | null;
|
|
99
|
+
level: "info" | "warn" | "error";
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Turn one balance reading into the verdict both producers log.
|
|
103
|
+
*
|
|
104
|
+
* Pure on purpose: the thresholds are the alerting policy, so they are unit-testable
|
|
105
|
+
* without a provider, a cron, or a network.
|
|
106
|
+
*/
|
|
107
|
+
export declare function classifyManagedBalance(input: {
|
|
108
|
+
provider: string;
|
|
109
|
+
/** The balance fetcher's own status. Anything but "ok" means we are blind. */
|
|
110
|
+
fetchStatus: "ok" | "auth_error" | "rate_limited" | "error";
|
|
111
|
+
balanceRemaining: number | null;
|
|
112
|
+
env?: Record<string, string | undefined>;
|
|
113
|
+
}): ProviderBalanceVerdict;
|