@ossy/platform 3.15.0 → 3.17.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.
Files changed (34) hide show
  1. package/package.json +9 -8
  2. package/src/audit/audit-helpers.js +60 -6
  3. package/src/audit/audit-helpers.spec.js +42 -0
  4. package/src/audit/task-run-list.aggregate.js +3 -0
  5. package/src/audit/task-run-service.js +10 -2
  6. package/src/audit/task-run.aggregate.js +2 -0
  7. package/src/file.schema.js +0 -4
  8. package/src/metering/assert-llm-usage-budget.js +83 -0
  9. package/src/metering/assert-llm-usage-budget.spec.js +129 -0
  10. package/src/metering/get-workspace-llm-usage.action.js +5 -0
  11. package/src/metering/get-workspace-llm-usage.task.js +31 -0
  12. package/src/metering/get-workspace-llm-usage.task.spec.js +74 -0
  13. package/src/metering/get-workspace-task-metering.action.js +5 -0
  14. package/src/metering/get-workspace-task-metering.task.js +30 -0
  15. package/src/metering/get-workspace-task-metering.task.spec.js +43 -0
  16. package/src/metering/index.js +11 -0
  17. package/src/metering/llm-usage.aggregate.js +127 -0
  18. package/src/metering/llm-usage.aggregate.spec.js +95 -0
  19. package/src/metering/metering-service.js +13 -0
  20. package/src/metering/metering-service.spec.js +62 -0
  21. package/src/metering/task-metering.aggregate.js +108 -0
  22. package/src/metering/task-metering.aggregate.spec.js +71 -0
  23. package/src/metering/usage-month.js +7 -0
  24. package/src/runtime.js +2 -0
  25. package/src/server.js +2 -0
  26. package/src/ssr/detect-mobile-device.js +39 -0
  27. package/src/ssr/detect-mobile-device.spec.js +50 -0
  28. package/src/storage/storage-keys.js +24 -0
  29. package/src/storage/storage-keys.spec.js +12 -1
  30. package/src/tasks/in-process-sdk.js +1 -49
  31. package/src/tasks/in-process-sdk.spec.js +1 -39
  32. package/src/tasks/task-service.js +270 -41
  33. package/src/tasks/task-service.spec.js +108 -0
  34. package/src/text-completion.schema.js +37 -0
@@ -0,0 +1,43 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const getProjectionMock = jest.fn()
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ getProjection: getProjectionMock,
7
+ }))
8
+
9
+ const { run } = await import('./get-workspace-task-metering.task.js')
10
+
11
+ describe('get-workspace-task-metering task', () => {
12
+ beforeEach(() => {
13
+ getProjectionMock.mockReset()
14
+ })
15
+
16
+ it('requires a workspaceId', async () => {
17
+ await expect(run({ payload: {} })).rejects.toMatchObject({ status: 400 })
18
+ })
19
+
20
+ it('returns a zeroed bucket for a workspace with no task runs yet', async () => {
21
+ getProjectionMock.mockResolvedValue(null)
22
+ const result = await run({ payload: { workspaceId: 'ws-1', month: '2026-09' } })
23
+ expect(result).toEqual({
24
+ workspaceId: 'ws-1',
25
+ month: '2026-09',
26
+ requests: 0,
27
+ failures: 0,
28
+ durationMs: 0,
29
+ byModule: {},
30
+ })
31
+ })
32
+
33
+ it('returns the current month bucket from the projection', async () => {
34
+ getProjectionMock.mockResolvedValue({
35
+ byMonth: {
36
+ '2026-09': { requests: 12, failures: 1, durationMs: 4500, byModule: { '@ossy/media-tasks': { requests: 12, durationMs: 4500 } } },
37
+ },
38
+ })
39
+
40
+ const result = await run({ req: { workspaceId: 'ws-1' }, payload: { month: '2026-09' } })
41
+ expect(result).toMatchObject({ workspaceId: 'ws-1', month: '2026-09', requests: 12, durationMs: 4500 })
42
+ })
43
+ })
@@ -0,0 +1,11 @@
1
+ export { MeteringService } from './metering-service.js'
2
+ export { currentUsageMonth } from './usage-month.js'
3
+ export { LlmUsageProjection } from './llm-usage.aggregate.js'
4
+ export { TaskMeteringProjection } from './task-metering.aggregate.js'
5
+ export {
6
+ DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP,
7
+ assertLlmUsageBudget,
8
+ llmWorkspaceMonthlyTokenCap,
9
+ resolveLlmMonthlyTokenLimit,
10
+ resolveWorkspaceIdForBudget,
11
+ } from './assert-llm-usage-budget.js'
@@ -0,0 +1,127 @@
1
+ import { currentUsageMonth } from './usage-month.js'
2
+
3
+ export { currentUsageMonth }
4
+
5
+ /**
6
+ * ADR 0017 — per-workspace LLM token usage, folded at write.
7
+ *
8
+ * Bounded, time-bucketed projection over `@ossy/platform/schema/meter-event`
9
+ * `Recorded` events. Only events carrying LLM usage (`promptTokens` /
10
+ * `completionTokens`, set via `TaskRunService` → `MeteringService.record()`
11
+ * when a task result includes `usage`) affect state — everything else is a
12
+ * no-op `Apply` (durationMs/success-only meter events for non-LLM tasks).
13
+ *
14
+ * Named generically (not after a specific provider) — today's LLM tasks
15
+ * (`run-prompt`, `visual-content-descriptors`) call OpenRouter, but nothing
16
+ * in this metering layer assumes that; swapping the provider integration
17
+ * later needs no rename here.
18
+ *
19
+ * Scope: one document per workspace. Snapshot size is bounded by
20
+ * months × distinct models, not one row per request — mirrors
21
+ * `@ossy/analytics`'s `PageViewLocationProjection` day-bucket shape.
22
+ *
23
+ * Read via `getProjection(workspaceId, LlmUsageProjection.ProjectionId)`
24
+ * (`@ossy/event-store`). This is the same data backing both the workspace
25
+ * settings dashboard stat and `assertLlmUsageBudget`'s hard cap check.
26
+ *
27
+ * See `TaskMeteringProjection` (`./task-metering.aggregate.js`) for the
28
+ * general, all-tasks count/duration counterpart to this LLM-specific one.
29
+ */
30
+ export class LlmUsageProjection {
31
+
32
+ static kind = 'projection'
33
+ static ProjectionId = '@ossy/platform/data/llm-usage'
34
+
35
+ static sources = [
36
+ { type: '@ossy/platform/schema/meter-event', event: 'Recorded' },
37
+ ]
38
+
39
+ /**
40
+ * @param {object} event
41
+ * @returns {string | null}
42
+ */
43
+ static scopeFromEvent (event) {
44
+ const workspaceId = event.payload?.belongsTo ?? event.payload?.workspaceId
45
+ return typeof workspaceId === 'string' && workspaceId ? workspaceId : null
46
+ }
47
+
48
+ static cacheKeys (scopeId) {
49
+ return [
50
+ `projection:${LlmUsageProjection.ProjectionId}:${scopeId}`,
51
+ 'action:@ossy/platform/actions/get-workspace-llm-usage',
52
+ ]
53
+ }
54
+
55
+ /**
56
+ * @param {string} scopeId workspaceId
57
+ */
58
+ static initialState (scopeId) {
59
+ return { workspaceId: scopeId, byMonth: {} }
60
+ }
61
+
62
+ /**
63
+ * @param {object} event
64
+ * @param {{ workspaceId?: string, byMonth?: Record<string, MonthBucket> }} state
65
+ * @returns {{ workspaceId: string, byMonth: Record<string, MonthBucket> }}
66
+ */
67
+ static Apply (event, state) {
68
+ const payload = event.payload ?? {}
69
+ const promptTokens = typeof payload.promptTokens === 'number' ? payload.promptTokens : null
70
+ const completionTokens = typeof payload.completionTokens === 'number' ? payload.completionTokens : null
71
+ if (promptTokens == null && completionTokens == null) {
72
+ // Not an LLM-usage meter event (e.g. a plain task/action record) — no-op.
73
+ return { workspaceId: state.workspaceId, byMonth: state.byMonth ?? {} }
74
+ }
75
+
76
+ const totalTokens = typeof payload.totalTokens === 'number'
77
+ ? payload.totalTokens
78
+ : (promptTokens ?? 0) + (completionTokens ?? 0)
79
+
80
+ const at = Number(event.created)
81
+ const month = new Date(Number.isFinite(at) && at > 0 ? at : Date.now()).toISOString().slice(0, 7)
82
+
83
+ const byMonth = { ...(state.byMonth ?? {}) }
84
+ const bucket = byMonth[month] ?? emptyBucket()
85
+ const modelKey = typeof payload.model === 'string' && payload.model ? payload.model : 'unknown'
86
+ const modelBucket = bucket.byModel[modelKey] ?? emptyModelBucket()
87
+
88
+ byMonth[month] = {
89
+ requests: bucket.requests + 1,
90
+ promptTokens: bucket.promptTokens + (promptTokens ?? 0),
91
+ completionTokens: bucket.completionTokens + (completionTokens ?? 0),
92
+ totalTokens: bucket.totalTokens + totalTokens,
93
+ byModel: {
94
+ ...bucket.byModel,
95
+ [modelKey]: {
96
+ requests: modelBucket.requests + 1,
97
+ promptTokens: modelBucket.promptTokens + (promptTokens ?? 0),
98
+ completionTokens: modelBucket.completionTokens + (completionTokens ?? 0),
99
+ },
100
+ },
101
+ }
102
+
103
+ return { workspaceId: state.workspaceId, byMonth }
104
+ }
105
+ }
106
+
107
+ /** @returns {MonthBucket} */
108
+ function emptyBucket () {
109
+ return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, byModel: {} }
110
+ }
111
+
112
+ function emptyModelBucket () {
113
+ return { requests: 0, promptTokens: 0, completionTokens: 0 }
114
+ }
115
+
116
+ /**
117
+ * @typedef {{
118
+ * requests: number,
119
+ * promptTokens: number,
120
+ * completionTokens: number,
121
+ * totalTokens: number,
122
+ * byModel: Record<string, { requests: number, promptTokens: number, completionTokens: number }>,
123
+ * }} MonthBucket
124
+ */
125
+
126
+ export { LlmUsageProjection as Aggregate }
127
+ export const id = 'platform/data/llm-usage'
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { LlmUsageProjection, currentUsageMonth } from './llm-usage.aggregate.js'
3
+
4
+ describe('LlmUsageProjection', () => {
5
+ it('scopes by workspaceId (belongsTo)', () => {
6
+ expect(LlmUsageProjection.scopeFromEvent({ payload: { belongsTo: 'ws-1' } })).toBe('ws-1')
7
+ expect(LlmUsageProjection.scopeFromEvent({ payload: {} })).toBeNull()
8
+ })
9
+
10
+ it('is a no-op for meter events without token usage (non-LLM tasks)', () => {
11
+ const state = LlmUsageProjection.initialState('ws-1')
12
+ const next = LlmUsageProjection.Apply({
13
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
14
+ payload: { kind: 'task', taskId: '@ossy/booking/tasks/confirm', durationMs: 12, success: true },
15
+ }, state)
16
+ expect(next).toEqual({ workspaceId: 'ws-1', byMonth: {} })
17
+ })
18
+
19
+ it('folds prompt/completion tokens into the event month, keyed by model', () => {
20
+ const state = LlmUsageProjection.initialState('ws-1')
21
+ const next = LlmUsageProjection.Apply({
22
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
23
+ payload: {
24
+ belongsTo: 'ws-1',
25
+ model: 'openai/gpt-4o-mini',
26
+ promptTokens: 100,
27
+ completionTokens: 20,
28
+ totalTokens: 120,
29
+ },
30
+ }, state)
31
+
32
+ expect(next.byMonth['2026-09']).toEqual({
33
+ requests: 1,
34
+ promptTokens: 100,
35
+ completionTokens: 20,
36
+ totalTokens: 120,
37
+ byModel: {
38
+ 'openai/gpt-4o-mini': { requests: 1, promptTokens: 100, completionTokens: 20 },
39
+ },
40
+ })
41
+ })
42
+
43
+ it('accumulates across requests and models within the same month', () => {
44
+ const first = LlmUsageProjection.Apply({
45
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
46
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20 },
47
+ }, LlmUsageProjection.initialState('ws-1'))
48
+
49
+ const second = LlmUsageProjection.Apply({
50
+ created: Date.parse('2026-09-20T08:00:00.000Z'),
51
+ payload: { belongsTo: 'ws-1', model: 'google/gemini-2.5-flash-lite', promptTokens: 50, completionTokens: 10 },
52
+ }, first)
53
+
54
+ expect(second.byMonth['2026-09']).toEqual({
55
+ requests: 2,
56
+ promptTokens: 150,
57
+ completionTokens: 30,
58
+ totalTokens: 180,
59
+ byModel: {
60
+ 'openai/gpt-4o-mini': { requests: 1, promptTokens: 100, completionTokens: 20 },
61
+ 'google/gemini-2.5-flash-lite': { requests: 1, promptTokens: 50, completionTokens: 10 },
62
+ },
63
+ })
64
+ })
65
+
66
+ it('buckets separately across months', () => {
67
+ const first = LlmUsageProjection.Apply({
68
+ created: Date.parse('2026-08-31T23:00:00.000Z'),
69
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20 },
70
+ }, LlmUsageProjection.initialState('ws-1'))
71
+
72
+ const second = LlmUsageProjection.Apply({
73
+ created: Date.parse('2026-09-01T01:00:00.000Z'),
74
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 5, completionTokens: 1 },
75
+ }, first)
76
+
77
+ expect(Object.keys(second.byMonth).sort()).toEqual(['2026-08', '2026-09'])
78
+ expect(second.byMonth['2026-08'].totalTokens).toBe(120)
79
+ expect(second.byMonth['2026-09'].totalTokens).toBe(6)
80
+ })
81
+
82
+ it('invalidates the workspace usage action cache', () => {
83
+ expect(LlmUsageProjection.cacheKeys('ws-1')).toEqual([
84
+ 'projection:@ossy/platform/data/llm-usage:ws-1',
85
+ 'action:@ossy/platform/actions/get-workspace-llm-usage',
86
+ ])
87
+ })
88
+ })
89
+
90
+ describe('currentUsageMonth', () => {
91
+ it('passes through an explicit month, else defaults to the current UTC month', () => {
92
+ expect(currentUsageMonth('2026-01')).toBe('2026-01')
93
+ expect(currentUsageMonth()).toBe(new Date().toISOString().slice(0, 7))
94
+ })
95
+ })
@@ -23,6 +23,10 @@ export const MeteringService = {
23
23
  * actionInvocationId?: string | null,
24
24
  * taskRunId?: string | null,
25
25
  * trigger?: string | null,
26
+ * model?: string | null,
27
+ * promptTokens?: number | null,
28
+ * completionTokens?: number | null,
29
+ * totalTokens?: number | null,
26
30
  * }} record
27
31
  */
28
32
  async record (record) {
@@ -38,6 +42,10 @@ export const MeteringService = {
38
42
  actionInvocationId = null,
39
43
  taskRunId = null,
40
44
  trigger = null,
45
+ model = null,
46
+ promptTokens = null,
47
+ completionTokens = null,
48
+ totalTokens = null,
41
49
  } = record
42
50
 
43
51
  const moduleId = taskId
@@ -59,6 +67,11 @@ export const MeteringService = {
59
67
  actionInvocationId,
60
68
  taskRunId,
61
69
  trigger,
70
+ // ADR 0017 — optional LLM/usage-metering fields; null for non-metered runs.
71
+ model,
72
+ promptTokens,
73
+ completionTokens,
74
+ totalTokens,
62
75
  recordedAt: Date.now(),
63
76
  }
64
77
 
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const appendResourceEventMock = jest.fn().mockResolvedValue(undefined)
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ EventStore: { AppendResourceEvent: appendResourceEventMock },
7
+ }))
8
+
9
+ jest.unstable_mockModule('@ossy/observability', () => ({
10
+ createLogger: () => ({ debug () {}, info () {}, warn () {}, error () {} }),
11
+ metrics: { increment () {}, timing () {} },
12
+ }))
13
+
14
+ const { MeteringService } = await import('./metering-service.js')
15
+
16
+ describe('MeteringService.record', () => {
17
+ beforeEach(() => {
18
+ appendResourceEventMock.mockClear()
19
+ })
20
+
21
+ it('defaults usage fields to null for a plain task/action record', async () => {
22
+ await MeteringService.record({
23
+ kind: 'task',
24
+ taskId: '@ossy/booking/tasks/confirm',
25
+ workspaceId: 'ws-1',
26
+ durationMs: 12,
27
+ success: true,
28
+ })
29
+
30
+ expect(appendResourceEventMock).toHaveBeenCalledTimes(1)
31
+ const [{ payload }] = appendResourceEventMock.mock.calls[0]
32
+ expect(payload).toMatchObject({
33
+ model: null,
34
+ promptTokens: null,
35
+ completionTokens: null,
36
+ totalTokens: null,
37
+ })
38
+ })
39
+
40
+ it('carries model/token usage onto the meter-event payload', async () => {
41
+ await MeteringService.record({
42
+ kind: 'task',
43
+ taskId: '@ossy/media-tasks/tasks/run-prompt',
44
+ workspaceId: 'ws-1',
45
+ durationMs: 800,
46
+ success: true,
47
+ model: 'openai/gpt-4o-mini',
48
+ promptTokens: 100,
49
+ completionTokens: 20,
50
+ totalTokens: 120,
51
+ })
52
+
53
+ const [{ payload }] = appendResourceEventMock.mock.calls[0]
54
+ expect(payload).toMatchObject({
55
+ belongsTo: 'ws-1',
56
+ model: 'openai/gpt-4o-mini',
57
+ promptTokens: 100,
58
+ completionTokens: 20,
59
+ totalTokens: 120,
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,108 @@
1
+ import { currentUsageMonth } from './usage-month.js'
2
+
3
+ export { currentUsageMonth }
4
+
5
+ /**
6
+ * ADR 0017 (amendment) — general, all-tasks execution volume per workspace,
7
+ * folded at write. Where `LlmUsageProjection` only folds meter events
8
+ * carrying LLM token usage, this folds **every** `kind: 'task'` meter event
9
+ * `MeteringService.record()` writes — regardless of whether the task used
10
+ * an LLM — into a bounded, monthly-bucketed count + total execution
11
+ * time. Intended as a second, non-token billing dimension ("how much
12
+ * automation did this workspace run") independent of AI usage.
13
+ *
14
+ * Snapshot size is bounded by months × distinct modules, mirroring
15
+ * `LlmUsageProjection` / `@ossy/analytics`'s day-bucket shape.
16
+ */
17
+ export class TaskMeteringProjection {
18
+
19
+ static kind = 'projection'
20
+ static ProjectionId = '@ossy/platform/data/task-metering'
21
+
22
+ static sources = [
23
+ { type: '@ossy/platform/schema/meter-event', event: 'Recorded' },
24
+ ]
25
+
26
+ /**
27
+ * @param {object} event
28
+ * @returns {string | null}
29
+ */
30
+ static scopeFromEvent (event) {
31
+ const workspaceId = event.payload?.belongsTo ?? event.payload?.workspaceId
32
+ return typeof workspaceId === 'string' && workspaceId ? workspaceId : null
33
+ }
34
+
35
+ static cacheKeys (scopeId) {
36
+ return [
37
+ `projection:${TaskMeteringProjection.ProjectionId}:${scopeId}`,
38
+ 'action:@ossy/platform/actions/get-workspace-task-metering',
39
+ ]
40
+ }
41
+
42
+ /**
43
+ * @param {string} scopeId workspaceId
44
+ */
45
+ static initialState (scopeId) {
46
+ return { workspaceId: scopeId, byMonth: {} }
47
+ }
48
+
49
+ /**
50
+ * @param {object} event
51
+ * @param {{ workspaceId?: string, byMonth?: Record<string, TaskMonthBucket> }} state
52
+ * @returns {{ workspaceId: string, byMonth: Record<string, TaskMonthBucket> }}
53
+ */
54
+ static Apply (event, state) {
55
+ const payload = event.payload ?? {}
56
+ if (payload.kind !== 'task') {
57
+ // Action invocations are metered too, but this dimension is task
58
+ // execution volume only.
59
+ return { workspaceId: state.workspaceId, byMonth: state.byMonth ?? {} }
60
+ }
61
+
62
+ const at = Number(event.created)
63
+ const month = new Date(Number.isFinite(at) && at > 0 ? at : Date.now()).toISOString().slice(0, 7)
64
+
65
+ const byMonth = { ...(state.byMonth ?? {}) }
66
+ const bucket = byMonth[month] ?? emptyBucket()
67
+ const moduleKey = typeof payload.moduleId === 'string' && payload.moduleId ? payload.moduleId : 'unknown'
68
+ const moduleBucket = bucket.byModule[moduleKey] ?? emptyModuleBucket()
69
+ const durationMs = typeof payload.durationMs === 'number' ? payload.durationMs : 0
70
+ const failed = payload.success === false
71
+
72
+ byMonth[month] = {
73
+ requests: bucket.requests + 1,
74
+ failures: bucket.failures + (failed ? 1 : 0),
75
+ durationMs: bucket.durationMs + durationMs,
76
+ byModule: {
77
+ ...bucket.byModule,
78
+ [moduleKey]: {
79
+ requests: moduleBucket.requests + 1,
80
+ durationMs: moduleBucket.durationMs + durationMs,
81
+ },
82
+ },
83
+ }
84
+
85
+ return { workspaceId: state.workspaceId, byMonth }
86
+ }
87
+ }
88
+
89
+ /** @returns {TaskMonthBucket} */
90
+ function emptyBucket () {
91
+ return { requests: 0, failures: 0, durationMs: 0, byModule: {} }
92
+ }
93
+
94
+ function emptyModuleBucket () {
95
+ return { requests: 0, durationMs: 0 }
96
+ }
97
+
98
+ /**
99
+ * @typedef {{
100
+ * requests: number,
101
+ * failures: number,
102
+ * durationMs: number,
103
+ * byModule: Record<string, { requests: number, durationMs: number }>,
104
+ * }} TaskMonthBucket
105
+ */
106
+
107
+ export { TaskMeteringProjection as Aggregate }
108
+ export const id = 'platform/data/task-metering'
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { TaskMeteringProjection } from './task-metering.aggregate.js'
3
+ import { currentUsageMonth } from './usage-month.js'
4
+
5
+ describe('TaskMeteringProjection', () => {
6
+ it('scopes by workspaceId (belongsTo)', () => {
7
+ expect(TaskMeteringProjection.scopeFromEvent({ payload: { belongsTo: 'ws-1' } })).toBe('ws-1')
8
+ expect(TaskMeteringProjection.scopeFromEvent({ payload: {} })).toBeNull()
9
+ })
10
+
11
+ it('is a no-op for action-kind meter events (task execution volume only)', () => {
12
+ const state = TaskMeteringProjection.initialState('ws-1')
13
+ const next = TaskMeteringProjection.Apply({
14
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
15
+ payload: { kind: 'action', actionId: '@ossy/booking/actions/create', durationMs: 12 },
16
+ }, state)
17
+ expect(next).toEqual({ workspaceId: 'ws-1', byMonth: {} })
18
+ })
19
+
20
+ it('counts every task run and sums duration, regardless of success', () => {
21
+ const first = TaskMeteringProjection.Apply({
22
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
23
+ payload: { kind: 'task', taskId: '@ossy/media-tasks/tasks/run-prompt', moduleId: '@ossy/media-tasks', durationMs: 800, success: true },
24
+ }, TaskMeteringProjection.initialState('ws-1'))
25
+
26
+ const second = TaskMeteringProjection.Apply({
27
+ created: Date.parse('2026-09-20T08:00:00.000Z'),
28
+ payload: { kind: 'task', taskId: '@ossy/booking/tasks/confirm', moduleId: '@ossy/booking', durationMs: 50, success: false },
29
+ }, first)
30
+
31
+ expect(second.byMonth['2026-09']).toEqual({
32
+ requests: 2,
33
+ failures: 1,
34
+ durationMs: 850,
35
+ byModule: {
36
+ '@ossy/media-tasks': { requests: 1, durationMs: 800 },
37
+ '@ossy/booking': { requests: 1, durationMs: 50 },
38
+ },
39
+ })
40
+ })
41
+
42
+ it('buckets separately across months', () => {
43
+ const first = TaskMeteringProjection.Apply({
44
+ created: Date.parse('2026-08-31T23:00:00.000Z'),
45
+ payload: { kind: 'task', moduleId: '@ossy/media-tasks', durationMs: 100, success: true },
46
+ }, TaskMeteringProjection.initialState('ws-1'))
47
+
48
+ const second = TaskMeteringProjection.Apply({
49
+ created: Date.parse('2026-09-01T01:00:00.000Z'),
50
+ payload: { kind: 'task', moduleId: '@ossy/media-tasks', durationMs: 20, success: true },
51
+ }, first)
52
+
53
+ expect(Object.keys(second.byMonth).sort()).toEqual(['2026-08', '2026-09'])
54
+ expect(second.byMonth['2026-08'].durationMs).toBe(100)
55
+ expect(second.byMonth['2026-09'].durationMs).toBe(20)
56
+ })
57
+
58
+ it('invalidates the workspace task-metering action cache', () => {
59
+ expect(TaskMeteringProjection.cacheKeys('ws-1')).toEqual([
60
+ 'projection:@ossy/platform/data/task-metering:ws-1',
61
+ 'action:@ossy/platform/actions/get-workspace-task-metering',
62
+ ])
63
+ })
64
+ })
65
+
66
+ describe('currentUsageMonth (re-exported)', () => {
67
+ it('passes through an explicit month, else defaults to the current UTC month', () => {
68
+ expect(currentUsageMonth('2026-01')).toBe('2026-01')
69
+ expect(currentUsageMonth()).toBe(new Date().toISOString().slice(0, 7))
70
+ })
71
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @param {string} [month] `YYYY-MM`; defaults to the current UTC month.
3
+ * @returns {string}
4
+ */
5
+ export function currentUsageMonth (month) {
6
+ return typeof month === 'string' && month ? month : new Date().toISOString().slice(0, 7)
7
+ }
package/src/runtime.js CHANGED
@@ -12,6 +12,7 @@ import { loadSite, invalidateSite } from './site-loader.js'
12
12
  import { createLogger } from '@ossy/observability'
13
13
  import { mountHealthEndpoint } from './health.js'
14
14
  import { readWorkspaceIdFromCookies } from './user-app-settings.js'
15
+ import { detectMobileDevice } from './ssr/detect-mobile-device.js'
15
16
 
16
17
  const log = createLogger('platform')
17
18
 
@@ -214,6 +215,7 @@ export async function startRuntime ({ port } = {}) {
214
215
  taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
215
216
  url: requestUrl,
216
217
  isAuthenticated: !!req.isAuthenticated,
218
+ isMobileDevice: detectMobileDevice(req),
217
219
  language,
218
220
  messages: cloneSerializable(messages),
219
221
  ...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
package/src/server.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  withTimeout,
38
38
  } from './request-diagnostics.js'
39
39
  import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
40
+ import { detectMobileDevice } from './ssr/detect-mobile-device.js'
40
41
  import {
41
42
  buildEnableablePackageSet,
42
43
  setEnableablePackages,
@@ -578,6 +579,7 @@ export async function startServer (options = {}) {
578
579
  taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
579
580
  url: requestUrl,
580
581
  isAuthenticated: !!req.isAuthenticated,
582
+ isMobileDevice: detectMobileDevice(req),
581
583
  language,
582
584
  messages: cloneSerializable(messages),
583
585
  ...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SSR device detection for the app shell (fixes #871 — desktop sidenav
3
+ * flashing on mobile because SSR always assumed a desktop viewport).
4
+ *
5
+ * Primary signal: the `Sec-CH-UA-Mobile` client hint (Chromium browsers
6
+ * send it on every request once `Accept-CH` opts in, no round trip needed).
7
+ * It is a structured boolean: `?1` (mobile) or `?0` (desktop).
8
+ *
9
+ * Fallback: a `User-Agent` regex for browsers that don't send the client
10
+ * hint (Safari, Firefox). This is a best-effort guess only — the client
11
+ * still re-resolves the real value via `matchMedia` after mount.
12
+ */
13
+
14
+ const MOBILE_USER_AGENT_PATTERN = /Mobi|Android|iPhone|iPad|iPod/i
15
+
16
+ /**
17
+ * @param {string | undefined | null} headerValue raw `Sec-CH-UA-Mobile` header value
18
+ * @returns {boolean | null} `true`/`false` when the hint is present, `null` when absent/malformed
19
+ */
20
+ export function parseSecChUaMobile (headerValue) {
21
+ if (typeof headerValue !== 'string') return null
22
+ const trimmed = headerValue.trim()
23
+ if (trimmed === '?1') return true
24
+ if (trimmed === '?0') return false
25
+ return null
26
+ }
27
+
28
+ /**
29
+ * @param {import('express').Request | undefined} req
30
+ * @returns {boolean} best-effort guess of whether the request came from a mobile device
31
+ */
32
+ export function detectMobileDevice (req) {
33
+ const secChUaMobile = req?.headers?.['sec-ch-ua-mobile'] ?? req?.get?.('Sec-CH-UA-Mobile')
34
+ const hinted = parseSecChUaMobile(secChUaMobile)
35
+ if (hinted !== null) return hinted
36
+
37
+ const userAgent = req?.headers?.['user-agent'] ?? req?.get?.('User-Agent') ?? ''
38
+ return MOBILE_USER_AGENT_PATTERN.test(userAgent)
39
+ }