6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
import { dbReplica } from '@sim/db'
|
|
import { copilotMessages, workflowExecutionLogs } from '@sim/db/schema'
|
|
import { sql } from 'drizzle-orm'
|
|
import { zonedWallClock, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
|
|
|
|
const REPORTING_TIME_ZONE = 'America/Los_Angeles'
|
|
|
|
/**
|
|
* Reporting-only formula. Source rows remain unweighted so changing either
|
|
* assumption never requires a migration or a historical data rewrite.
|
|
*/
|
|
export const GLOBAL_WORK_FORMULA = Object.freeze({
|
|
minutesPerUnit: 5,
|
|
globalAnnualHours: 2_510_000_000_000,
|
|
})
|
|
|
|
interface AggregatedRow extends Record<string, unknown> {
|
|
source: 'workflow' | 'mothership'
|
|
date: string
|
|
units: number | string
|
|
}
|
|
|
|
export interface GlobalWorkSummary {
|
|
month: string
|
|
label: string
|
|
isCurrentMonth: boolean
|
|
attribution: 'estimated'
|
|
scope:
|
|
| { type: 'global'; id: null }
|
|
| { type: 'user'; id: string }
|
|
| { type: 'organization'; id: string }
|
|
formula: {
|
|
minutesPerUnit: number
|
|
globalAnnualHours: number
|
|
}
|
|
units: number
|
|
humanEquivalentHours: number
|
|
annualizedPercentGlobalWork: number
|
|
sources: Array<{
|
|
source: 'workflow' | 'mothership'
|
|
units: number
|
|
humanEquivalentHours: number
|
|
}>
|
|
daily: Array<{
|
|
date: string
|
|
units: number
|
|
workflow: number
|
|
mothership: number
|
|
}>
|
|
}
|
|
|
|
export type GlobalWorkFilter = { type: 'user'; id: string } | { type: 'organization'; id: string }
|
|
|
|
function addMonth(month: string, delta: number): string {
|
|
const [year, monthNumber] = month.split('-').map(Number)
|
|
const date = new Date(Date.UTC(year, monthNumber - 1 + delta, 1))
|
|
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`
|
|
}
|
|
|
|
export function getLatestCompletedGlobalWorkMonth(now: Date): string {
|
|
return addMonth(zonedWallClock(now, REPORTING_TIME_ZONE).slice(0, 7), -1)
|
|
}
|
|
|
|
export function getGlobalWorkMonthWindow(month: string): {
|
|
periodStart: Date
|
|
periodEnd: Date
|
|
} {
|
|
return {
|
|
periodStart: zonedWallClockToUtc(`${month}-01T00:00:00`, REPORTING_TIME_ZONE),
|
|
periodEnd: zonedWallClockToUtc(`${addMonth(month, 1)}-01T00:00:00`, REPORTING_TIME_ZONE),
|
|
}
|
|
}
|
|
|
|
function round(value: number, places: number): number {
|
|
return Number(value.toFixed(places))
|
|
}
|
|
|
|
function daysInMonth(month: string): number {
|
|
const [year, monthNumber] = month.split('-').map(Number)
|
|
return new Date(Date.UTC(year, monthNumber, 0)).getUTCDate()
|
|
}
|
|
|
|
export function buildZeroFilledGlobalWorkDailySeries(params: {
|
|
month: string
|
|
isCurrentMonth: boolean
|
|
now: Date
|
|
unitsByDate: ReadonlyMap<string, { workflow: number; mothership: number }>
|
|
}): GlobalWorkSummary['daily'] {
|
|
const allDays = daysInMonth(params.month)
|
|
const currentDay = Number(zonedWallClock(params.now, REPORTING_TIME_ZONE).slice(8, 10))
|
|
const dayCount = params.isCurrentMonth ? currentDay : allDays
|
|
return Array.from({ length: dayCount }, (_, index) => {
|
|
const date = `${params.month}-${String(index + 1).padStart(2, '0')}`
|
|
const counts = params.unitsByDate.get(date) ?? { workflow: 0, mothership: 0 }
|
|
return {
|
|
date,
|
|
units: counts.workflow + counts.mothership,
|
|
workflow: counts.workflow,
|
|
mothership: counts.mothership,
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Global Work is deliberately derived from canonical product records instead
|
|
* of duplicating every run/message into an analytics ledger.
|
|
*
|
|
* `usage_log` helps preserve a workflow's original billing entity through a
|
|
* workspace move, but it is not itself a unit counter: one execution/turn can
|
|
* have many positive-cost rows and zero-cost work has none. Workflow and
|
|
* Mothership cardinality therefore comes from their source tables.
|
|
*
|
|
* Historical paid eligibility remains an estimate because subscription rows do
|
|
* not retain a full entitlement timeline. Newer workflow rows use the immutable
|
|
* billing snapshot already persisted for billing; older workflows and all
|
|
* Mothership messages fall back to the current/last-known subscription state.
|
|
*/
|
|
async function queryEstimatedGlobalWorkUnits(
|
|
periodStart: Date,
|
|
periodEnd: Date,
|
|
filter?: GlobalWorkFilter
|
|
) {
|
|
// Raw `sql` parameters do not infer a timestamp encoder from the surrounding
|
|
// comparison. Bind through the matching schema columns so postgres.js
|
|
// receives UTC timestamp strings rather than JavaScript Date objects.
|
|
const workflowPeriodStart = sql.param(periodStart, workflowExecutionLogs.endedAt)
|
|
const workflowPeriodEnd = sql.param(periodEnd, workflowExecutionLogs.endedAt)
|
|
const mothershipPeriodStart = sql.param(periodStart, copilotMessages.createdAt)
|
|
const mothershipPeriodEnd = sql.param(periodEnd, copilotMessages.createdAt)
|
|
const actorUserId = filter?.type === 'user' ? filter.id : null
|
|
const billingOrganizationId = filter?.type === 'organization' ? filter.id : null
|
|
|
|
const result = await dbReplica.execute<AggregatedRow>(sql`
|
|
WITH workspace_billing AS (
|
|
SELECT
|
|
w.id AS workspace_id,
|
|
CASE
|
|
WHEN w.workspace_mode = 'organization' AND w.organization_id IS NOT NULL
|
|
THEN w.organization_id
|
|
ELSE w.billed_account_user_id
|
|
END AS billing_entity_id,
|
|
CASE
|
|
WHEN w.workspace_mode = 'organization' AND w.organization_id IS NOT NULL
|
|
THEN 'organization'::text
|
|
ELSE 'user'::text
|
|
END AS billing_entity_type,
|
|
COALESCE(org_owner.user_id, w.billed_account_user_id) AS billing_owner_user_id
|
|
FROM workspace w
|
|
LEFT JOIN LATERAL (
|
|
SELECT m.user_id
|
|
FROM member m
|
|
WHERE m.organization_id = w.organization_id AND m.role = 'owner'
|
|
LIMIT 1
|
|
) org_owner ON w.workspace_mode = 'organization' AND w.organization_id IS NOT NULL
|
|
),
|
|
workflow_units AS (
|
|
SELECT
|
|
'workflow'::text AS source,
|
|
wel.execution_id AS source_event_id,
|
|
wel.ended_at AS occurred_at,
|
|
COALESCE(
|
|
wel.execution_data #>> '{billingAttribution,billingEntity,id}',
|
|
usage_scope.billing_entity_id,
|
|
wb.billing_entity_id
|
|
) AS billing_entity_id,
|
|
COALESCE(
|
|
wel.execution_data #>> '{billingAttribution,billingEntity,type}',
|
|
usage_scope.billing_entity_type::text,
|
|
wb.billing_entity_type
|
|
) AS billing_entity_type,
|
|
COALESCE(
|
|
wel.execution_data #>> '{billingAttribution,billedAccountUserId}',
|
|
wb.billing_owner_user_id
|
|
) AS billing_owner_user_id,
|
|
COALESCE(
|
|
wel.execution_data #>> '{billingAttribution,actorUserId}',
|
|
wel.execution_data #>> '{environment,userId}',
|
|
usage_scope.user_id
|
|
) AS actor_user_id,
|
|
-- Presence matters independently of payerSubscription: an explicit
|
|
-- null payer means the run was free and must never be reclassified by
|
|
-- a later workspace upgrade.
|
|
wel.execution_data ? 'billingAttribution' AS has_billing_snapshot,
|
|
wel.execution_data #>> '{billingAttribution,payerSubscription,plan}' AS snapshot_plan,
|
|
wel.execution_data #>> '{billingAttribution,payerSubscription,status}' AS snapshot_status
|
|
FROM workflow_execution_logs wel
|
|
JOIN workspace_billing wb ON wb.workspace_id = wel.workspace_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT ul.billing_entity_id, ul.billing_entity_type, ul.user_id
|
|
FROM usage_log ul
|
|
WHERE NOT (wel.execution_data ? 'billingAttribution')
|
|
AND ul.execution_id = wel.execution_id
|
|
AND ul.source = 'workflow'
|
|
AND ul.billing_entity_id IS NOT NULL
|
|
ORDER BY ul.created_at
|
|
LIMIT 1
|
|
) usage_scope ON true
|
|
WHERE wel.ended_at >= ${workflowPeriodStart}
|
|
AND wel.ended_at < ${workflowPeriodEnd}
|
|
AND wel.status = 'completed'
|
|
AND wel.level = 'info'
|
|
),
|
|
mothership_candidates AS (
|
|
SELECT
|
|
'mothership'::text AS source,
|
|
cm.message_id AS source_event_id,
|
|
cm.created_at AS occurred_at,
|
|
wb.billing_entity_id,
|
|
wb.billing_entity_type,
|
|
wb.billing_owner_user_id,
|
|
cc.user_id AS actor_user_id,
|
|
false AS has_billing_snapshot,
|
|
NULL::text AS snapshot_plan,
|
|
NULL::text AS snapshot_status,
|
|
row_number() OVER (
|
|
-- Chat forks copy prior messages with their original id/timestamp.
|
|
-- message_id is unique only within a chat, so include the actor
|
|
-- and timestamp to avoid collapsing an unrelated cross-chat reuse.
|
|
PARTITION BY cc.user_id, cm.message_id, cm.created_at
|
|
ORDER BY cm.created_at, cm.id
|
|
) AS logical_message_copy
|
|
FROM copilot_messages cm
|
|
JOIN copilot_chats cc ON cc.id = cm.chat_id
|
|
JOIN workspace_billing wb ON wb.workspace_id = cc.workspace_id
|
|
WHERE cc.type = 'mothership'
|
|
AND cm.role = 'user'
|
|
AND cm.deleted_at IS NULL
|
|
AND cm.created_at >= ${mothershipPeriodStart}
|
|
AND cm.created_at < ${mothershipPeriodEnd}
|
|
),
|
|
source_units AS (
|
|
SELECT * FROM workflow_units
|
|
UNION ALL
|
|
SELECT
|
|
source,
|
|
source_event_id,
|
|
occurred_at,
|
|
billing_entity_id,
|
|
billing_entity_type,
|
|
billing_owner_user_id,
|
|
actor_user_id,
|
|
has_billing_snapshot,
|
|
snapshot_plan,
|
|
snapshot_status
|
|
FROM mothership_candidates
|
|
WHERE logical_message_copy = 1
|
|
),
|
|
eligible_units AS (
|
|
SELECT su.*
|
|
FROM source_units su
|
|
LEFT JOIN "user" actor ON actor.id = su.actor_user_id
|
|
LEFT JOIN "user" billing_owner ON billing_owner.id = su.billing_owner_user_id
|
|
WHERE lower(split_part(COALESCE(actor.email, ''), '@', 2)) NOT IN ('sim.ai', 'simstudio.ai')
|
|
AND lower(split_part(COALESCE(billing_owner.email, ''), '@', 2)) NOT IN ('sim.ai', 'simstudio.ai')
|
|
AND (${actorUserId}::text IS NULL OR su.actor_user_id = ${actorUserId})
|
|
AND (
|
|
${billingOrganizationId}::text IS NULL
|
|
OR (
|
|
su.billing_entity_type = 'organization'
|
|
AND su.billing_entity_id = ${billingOrganizationId}
|
|
)
|
|
)
|
|
AND (
|
|
(
|
|
su.has_billing_snapshot
|
|
AND su.snapshot_plan IS NOT NULL
|
|
AND su.snapshot_status IN ('active', 'past_due')
|
|
AND (
|
|
su.snapshot_plan IN ('enterprise', 'pro', 'team')
|
|
OR left(su.snapshot_plan, 4) = 'pro_'
|
|
OR left(su.snapshot_plan, 5) = 'team_'
|
|
)
|
|
)
|
|
OR (
|
|
NOT su.has_billing_snapshot
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM subscription s
|
|
WHERE s.reference_id = su.billing_entity_id
|
|
AND (
|
|
s.plan IN ('enterprise', 'pro', 'team')
|
|
OR left(s.plan, 4) = 'pro_'
|
|
OR left(s.plan, 5) = 'team_'
|
|
)
|
|
AND (s.trial_end IS NULL OR su.occurred_at >= s.trial_end)
|
|
AND (
|
|
s.status IN ('active', 'past_due')
|
|
-- A terminal cancellation is the only historical state we
|
|
-- can infer safely from the surviving row. period_end
|
|
-- alone is not evidence of payment: Stripe also populates
|
|
-- it for trialing, incomplete, unpaid, and expired rows.
|
|
OR (
|
|
s.status = 'canceled'
|
|
AND COALESCE(s.ended_at, s.canceled_at) >= su.occurred_at
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
SELECT
|
|
source,
|
|
to_char(
|
|
(occurred_at AT TIME ZONE 'UTC') AT TIME ZONE ${REPORTING_TIME_ZONE},
|
|
'YYYY-MM-DD'
|
|
) AS date,
|
|
count(*)::bigint AS units
|
|
FROM eligible_units
|
|
GROUP BY source, date
|
|
ORDER BY date, source
|
|
`)
|
|
return Array.from(result)
|
|
}
|
|
|
|
export async function getGlobalWorkSummary(
|
|
requestedMonth?: string,
|
|
now = new Date(),
|
|
filter?: GlobalWorkFilter
|
|
): Promise<GlobalWorkSummary> {
|
|
const month = requestedMonth ?? getLatestCompletedGlobalWorkMonth(now)
|
|
const currentMonth = zonedWallClock(now, REPORTING_TIME_ZONE).slice(0, 7)
|
|
const isCurrentMonth = month === currentMonth
|
|
const { periodStart, periodEnd } = getGlobalWorkMonthWindow(month)
|
|
const rows = await queryEstimatedGlobalWorkUnits(periodStart, periodEnd, filter)
|
|
|
|
const sourceUnits = { workflow: 0, mothership: 0 }
|
|
const unitsByDate = new Map<string, { workflow: number; mothership: number }>()
|
|
for (const row of rows) {
|
|
const units = Number(row.units)
|
|
sourceUnits[row.source] += units
|
|
const daily = unitsByDate.get(row.date) ?? { workflow: 0, mothership: 0 }
|
|
daily[row.source] += units
|
|
unitsByDate.set(row.date, daily)
|
|
}
|
|
|
|
const daily = buildZeroFilledGlobalWorkDailySeries({
|
|
month,
|
|
isCurrentMonth,
|
|
now,
|
|
unitsByDate,
|
|
})
|
|
const units = sourceUnits.workflow + sourceUnits.mothership
|
|
const humanEquivalentHours = units * (GLOBAL_WORK_FORMULA.minutesPerUnit / 60)
|
|
const elapsedFraction = isCurrentMonth
|
|
? Math.max(
|
|
0,
|
|
Math.min(
|
|
1,
|
|
(now.getTime() - periodStart.getTime()) / (periodEnd.getTime() - periodStart.getTime())
|
|
)
|
|
)
|
|
: 1
|
|
const projectedMonthlyHours =
|
|
isCurrentMonth && elapsedFraction > 0
|
|
? humanEquivalentHours / elapsedFraction
|
|
: humanEquivalentHours
|
|
const annualizedPercentGlobalWork =
|
|
((projectedMonthlyHours * 12) / GLOBAL_WORK_FORMULA.globalAnnualHours) * 100
|
|
|
|
return {
|
|
month,
|
|
label: isCurrentMonth ? 'Month to date · projected annualized' : 'Completed month',
|
|
isCurrentMonth,
|
|
attribution: 'estimated',
|
|
scope: filter ?? { type: 'global', id: null },
|
|
formula: GLOBAL_WORK_FORMULA,
|
|
units,
|
|
humanEquivalentHours: round(humanEquivalentHours, 2),
|
|
annualizedPercentGlobalWork: round(annualizedPercentGlobalWork, 10),
|
|
sources: (['workflow', 'mothership'] as const).map((source) => ({
|
|
source,
|
|
units: sourceUnits[source],
|
|
humanEquivalentHours: round(
|
|
sourceUnits[source] * (GLOBAL_WORK_FORMULA.minutesPerUnit / 60),
|
|
2
|
|
),
|
|
})),
|
|
daily,
|
|
}
|
|
}
|