From a829f6ff6afcec788e85e1dc795a02b92a8db8da Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 20:26:09 +0500 Subject: [PATCH 1/9] Split @rakkr/shared index barrel into domain modules Extract the ~1000-line index.ts into focused domain modules (rbac, audit, nodes, controller-settings, channel-maps, schedules, recordings, uploads, retention, health-events) matching the existing base.ts/channels.ts leaf pattern. index.ts is now a pure re-export barrel and the shared leaf primitives (isoDateSchema, timeOfDaySchema, audioCaptureBackendSchema) move into base.ts. Public export surface is unchanged; room-capabilities imports Permission from rbac.ts directly. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/audit.ts | 43 + packages/shared/src/base.ts | 11 + packages/shared/src/channel-maps.ts | 110 +++ packages/shared/src/controller-settings.ts | 44 + packages/shared/src/health-events.ts | 27 + packages/shared/src/index.ts | 994 +-------------------- packages/shared/src/nodes.ts | 150 ++++ packages/shared/src/rbac.ts | 117 +++ packages/shared/src/recordings.ts | 139 +++ packages/shared/src/retention.ts | 67 ++ packages/shared/src/room-capabilities.ts | 2 +- packages/shared/src/schedules.ts | 209 +++++ packages/shared/src/uploads.ts | 117 +++ 13 files changed, 1048 insertions(+), 982 deletions(-) create mode 100644 packages/shared/src/audit.ts create mode 100644 packages/shared/src/channel-maps.ts create mode 100644 packages/shared/src/controller-settings.ts create mode 100644 packages/shared/src/health-events.ts create mode 100644 packages/shared/src/nodes.ts create mode 100644 packages/shared/src/rbac.ts create mode 100644 packages/shared/src/recordings.ts create mode 100644 packages/shared/src/retention.ts create mode 100644 packages/shared/src/schedules.ts create mode 100644 packages/shared/src/uploads.ts diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts new file mode 100644 index 00000000..d98da13b --- /dev/null +++ b/packages/shared/src/audit.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; +import { permissionSchema, roleSchema } from "./rbac.js"; + +// Immutable audit record appended for every privileged action and denied +// attempt. `before`/`after` capture resource state diffs; `correlationIds` ties +// an event to runner runs, jobs, and sessions. +export const auditOutcomeSchema = z.enum(["allowed", "denied", "failed", "partial", "succeeded"]); +export const auditActorTypeSchema = z.enum(["node", "system", "user"]); + +export const auditEventSchema = z.object({ + action: z.string().min(1), + actor: z.object({ + id: z.string().min(1), + name: z.string().min(1), + roles: z.array(roleSchema), + type: auditActorTypeSchema, + }), + actorContext: z.object({ + ipAddress: z.string().optional(), + sessionId: z.string().optional(), + userAgent: z.string().optional(), + }), + after: z.record(z.string(), z.unknown()).optional(), + before: z.record(z.string(), z.unknown()).optional(), + correlationIds: z.record(z.string(), z.string()).optional(), + createdAt: isoDateTimeSchema, + details: z.record(z.string(), z.unknown()), + id: z.string().min(1), + outcome: auditOutcomeSchema, + permission: permissionSchema.optional(), + reason: z.string().optional(), + target: z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().min(1), + }), +}); + +export type AuditActorType = z.infer; +export type AuditEvent = z.infer; +export type AuditOutcome = z.infer; diff --git a/packages/shared/src/base.ts b/packages/shared/src/base.ts index 14a21c8b..baa2b192 100644 --- a/packages/shared/src/base.ts +++ b/packages/shared/src/base.ts @@ -43,3 +43,14 @@ export const uploadQueueStatusSchema = z.enum([ "succeeded", "cancelled", ]); + +// ISO 8601 calendar date with no time component (e.g. schedule exception dates). +export const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +// 24-hour wall-clock time "HH:MM" used by recurring schedule windows. +export const timeOfDaySchema = z.string().regex(/^\d{2}:\d{2}$/); +// Linux audio capture backend selected per node, schedule, or job. +export const audioCaptureBackendSchema = z.enum(["alsa", "jack", "pipewire"]); + +export type HealthSeverity = z.infer; +export type UploadProvider = z.infer; +export type UploadQueueStatus = z.infer; diff --git a/packages/shared/src/channel-maps.ts b/packages/shared/src/channel-maps.ts new file mode 100644 index 00000000..a55055af --- /dev/null +++ b/packages/shared/src/channel-maps.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; +import { channelModeSchema } from "./channels.js"; + +// A channel-map template maps captured source channels onto output channels, and +// is assignable to an interface or a whole node. Assignments carry history and +// can be staged as a plan before being applied. +export const templateAssignmentTargetSchema = z.enum(["interface", "node"]); + +export const channelMapEntrySchema = z.object({ + included: z.boolean(), + label: z.string().trim().min(1).max(160), + outputChannelIndex: z.number().int().positive().optional(), + sourceChannelIndex: z.number().int().positive(), +}); +export const channelMapTemplateSchema = z.object({ + channelMode: channelModeSchema, + entries: z.array(channelMapEntrySchema).min(1).max(128), + id: z.string().min(1), + name: z.string().min(1), + promotedAt: isoDateTimeSchema.optional(), + promotedFromTemplateId: z.string().min(1).optional(), + revision: z.number().int().positive().default(1), + tags: z.array(z.string().min(1)).default([]), +}); +export const channelMapTemplateInputSchema = z.object({ + channelMode: channelModeSchema.default("mono_to_stereo_mix"), + entries: z.array(channelMapEntrySchema).min(1).max(128), + id: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(160), + tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), +}); +export const channelMapTemplateUpdateSchema = z + .object({ + channelMode: channelModeSchema.optional(), + entries: z.array(channelMapEntrySchema).min(1).max(128).optional(), + name: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one channel map field is required"); +export const channelMapAssignmentHistorySchema = z.object({ + actorUserId: z.string().optional(), + changedAt: isoDateTimeSchema, + id: z.string().min(1), + nextTemplateId: z.string().min(1), + previousTemplateId: z.string().min(1).optional(), + reason: z.enum(["assigned", "rolled_back"]), +}); +export const channelMapTemplateAssignmentSchema = z.object({ + assignedAt: isoDateTimeSchema, + history: z.array(channelMapAssignmentHistorySchema).default([]), + id: z.string().min(1), + targetId: z.string().min(1), + targetType: templateAssignmentTargetSchema, + templateId: z.string().min(1), +}); +export const channelMapTemplateAssignmentInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentTargetInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, +}); +export const channelMapTemplateAssignmentBulkInputSchema = z.object({ + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentPlanStatusSchema = z.enum(["applied", "cancelled", "pending"]); +export const channelMapAssignmentPlanInputSchema = z.object({ + note: z.string().trim().max(500).optional(), + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentPlanSchema = z.object({ + appliedAt: isoDateTimeSchema.optional(), + appliedByUserId: z.string().optional(), + cancelledAt: isoDateTimeSchema.optional(), + createdAt: isoDateTimeSchema, + createdByUserId: z.string().optional(), + id: z.string().min(1), + note: z.string().optional(), + status: channelMapAssignmentPlanStatusSchema, + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().min(1), +}); +export const channelMapTemplateAssignmentRollbackInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, +}); + +export type ChannelMapAssignmentHistory = z.infer; +export type ChannelMapAssignmentPlan = z.infer; +export type ChannelMapAssignmentPlanInput = z.infer; +export type ChannelMapEntry = z.infer; +export type ChannelMapTemplate = z.infer; +export type ChannelMapTemplateAssignment = z.infer; +export type ChannelMapTemplateAssignmentInput = z.infer< + typeof channelMapTemplateAssignmentInputSchema +>; +export type ChannelMapTemplateAssignmentBulkInput = z.infer< + typeof channelMapTemplateAssignmentBulkInputSchema +>; +export type ChannelMapTemplateAssignmentRollbackInput = z.infer< + typeof channelMapTemplateAssignmentRollbackInputSchema +>; +export type ChannelMapTemplateInput = z.infer; +export type ChannelMapTemplateUpdate = z.infer; diff --git a/packages/shared/src/controller-settings.ts b/packages/shared/src/controller-settings.ts new file mode 100644 index 00000000..bcb3a8e9 --- /dev/null +++ b/packages/shared/src/controller-settings.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +// Day the operator console's schedule calendar starts its week on. +export const weekStartDaySchema = z.enum([ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]); +export const controllerSettingsSchema = z.object({ + controllerName: z.string().trim().min(1).max(160), + // Operator-chosen defaults that pre-fill the scheduling and ad-hoc recording + // forms. `null` means "no default set" (the forms fall back to the built-in + // profile/policy). One default per type keeps the "set as default" toggle + // single-select without per-policy flags. + defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().default(null), + defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + weekStartsOn: weekStartDaySchema.default("monday"), +}); +export const controllerSettingsUpdateSchema = z + .object({ + controllerName: z.string().trim().min(1).max(160).optional(), + // Nullable so an operator can clear a default (send `null`); omitted keeps + // the current value. + defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().optional(), + defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + weekStartsOn: weekStartDaySchema.optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one controller setting is required"); +export const defaultControllerSettings = controllerSettingsSchema.parse({ + controllerName: "Rakkr Controller", + weekStartsOn: "monday", +}); + +export type ControllerSettings = z.infer; +export type ControllerSettingsUpdate = z.infer; +export type WeekStartDay = z.infer; diff --git a/packages/shared/src/health-events.ts b/packages/shared/src/health-events.ts new file mode 100644 index 00000000..fc586821 --- /dev/null +++ b/packages/shared/src/health-events.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { healthSeveritySchema, isoDateTimeSchema } from "./base.js"; + +export const healthEventStatusSchema = z.enum(["open", "acknowledged", "suppressed", "resolved"]); + +export const healthEventSchema = z.object({ + acknowledgedAt: isoDateTimeSchema.nullable(), + acknowledgedBy: z.string().optional(), + details: z.record(z.string(), z.unknown()), + id: z.string().min(1), + nodeId: z.string().min(1).optional(), + openedAt: isoDateTimeSchema, + recordingId: z.string().optional(), + resolvedAt: isoDateTimeSchema.nullable(), + resolvedBy: z.string().optional(), + scheduleId: z.string().optional(), + severity: healthSeveritySchema, + status: healthEventStatusSchema, + suppressedAt: isoDateTimeSchema.nullable(), + suppressedBy: z.string().optional(), + suppressedUntil: isoDateTimeSchema.nullable(), + type: z.string().min(1), +}); + +export type HealthEvent = z.infer; +export type HealthEventStatus = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 317e8e45..ff95bcd4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,997 +1,29 @@ -import { z } from "zod"; - -import { - dbfsSchema, - healthSeveritySchema, - ianaTimeZoneSchema, - isoDateTimeSchema, - uploadProviderSchema, - uploadQueueStatusSchema, -} from "./base.js"; -import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; -import { recordingEnhancementSchema } from "./enhancement.js"; -import { recordingChunkSchema } from "./recording-chunks.js"; -import { accessGroupIdSchema, accessGroupSchema } from "./access-groups.js"; +// Public contract surface for @rakkr/shared. Each domain lives in its own module +// (see base.ts for the shared leaf primitives); this file only re-exports them so +// consumers keep importing everything from the package root. export * from "./access-groups.js"; export * from "./agent-version.js"; +export * from "./audit.js"; export * from "./base.js"; +export * from "./channel-maps.js"; export * from "./channels.js"; +export * from "./controller-settings.js"; export * from "./enhancement.js"; export * from "./health-event-summary.js"; +export * from "./health-events.js"; +export * from "./nodes.js"; export * from "./oidc.js"; export * from "./pagination.js"; +export * from "./rbac.js"; export * from "./recording-chunks.js"; export * from "./recording-job-summary.js"; export * from "./recording-profile.js"; +export * from "./recordings.js"; +export * from "./retention.js"; export * from "./room-capabilities.js"; export * from "./rooms.js"; +export * from "./schedules.js"; export * from "./switchers.js"; export * from "./upload-providers.js"; +export * from "./uploads.js"; export * from "./watchdog-policy.js"; - -export const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -const audioCaptureBackendSchema = z.enum(["alsa", "jack", "pipewire"]); -const timeOfDaySchema = z.string().regex(/^\d{2}:\d{2}$/); - -// "provisioning" is an enrolled node that has never made contact (no bootstrap, -// no heartbeat). It is excluded from liveness/offline monitoring until its first -// heartbeat flips it to a live status — see node-liveness / watchdog-node-liveness. -export const nodeStatusSchema = z.enum([ - "provisioning", - "online", - "offline", - "degraded", - "recording", - "alerting", -]); - -// A node is "reachable" (in contact and reporting) when its status is online, -// recording, degraded, or alerting. "offline" (heartbeat gone stale) and -// "provisioning" (enrolled but never contacted) are NOT reachable. Shared by the -// /metrics `rakkr_node_online` gauge and the dashboard active-node count so the -// two cannot diverge on how a never-contacted provisioning node is treated -// (a naive `status !== "offline"` counts provisioning as online — see audit N1/N2). -export function isNodeReachable(status: NodeStatus): boolean { - return ( - status === "online" || status === "recording" || status === "degraded" || status === "alerting" - ); -} - -export const healthEventStatusSchema = z.enum(["open", "acknowledged", "suppressed", "resolved"]); - -export const recordingSourceSchema = z.enum(["ad_hoc", "schedule"]); -export const recordingStatusSchema = z.enum([ - "queued", - "recording", - "completed", - "failed", - "cached", - "uploaded", - "partial", -]); -export const recordingJobStatusSchema = z.enum([ - "queued", - "running", - "stop_requested", - "cancelled", - "completed", - "failed", -]); - -export const templateAssignmentTargetSchema = z.enum(["interface", "node"]); - -export const permissions = [ - "audit:read", - "auth:manage", - "health:acknowledge", - "health:read", - "listen:monitor", - "metrics:read", - "node:control", - "node:manage", - "node:read", - "recording:control", - "recording:create", - "recording:delete", - "recording:download", - "recording:edit", - "recording:playback", - "recording:read", - "schedule:manage", - "schedule:read", - "settings:manage", - "settings:read", - "switcher:manage", - "switcher:map", - "switcher:read", - "system:admin", -] as const; - -export const roles = ["owner", "admin", "operator", "viewer", "auditor"] as const; -export type Permission = (typeof permissions)[number]; -export type Role = (typeof roles)[number]; -export const permissionSchema = z.enum(permissions); -export const roleSchema = z.enum(roles); -export const accessPolicyEffectSchema = z.enum(["allow", "deny"]); -export const accessPolicySubjectTypeSchema = z.enum(["user", "group", "everyone"]); -export const resourceGrantSchema = z.object({ - resourceId: z.string().min(1), - resourceType: z.string().min(1), -}); -export const accessPolicySchema = z.object({ - effect: accessPolicyEffectSchema, - id: z.string().min(1), - reason: z.string().optional(), - resourceId: z.string().min(1), - resourceType: z.string().min(1), - subjectId: z.string().optional(), - subjectType: accessPolicySubjectTypeSchema, -}); -export const accessPolicyInputSchema = accessPolicySchema.omit({ id: true }); -export const rolePermissions: Record = { - admin: permissions.filter((permission) => permission !== "system:admin"), - auditor: ["audit:read", "health:read", "metrics:read", "recording:read"], - operator: [ - "health:acknowledge", - "health:read", - "listen:monitor", - "metrics:read", - "node:control", - "node:read", - "recording:control", - "recording:create", - "recording:download", - "recording:edit", - "recording:playback", - "recording:read", - "schedule:manage", - "schedule:read", - "settings:read", - "switcher:map", - "switcher:read", - ], - owner: permissions, - viewer: [ - "health:read", - "metrics:read", - "node:read", - "recording:download", - "recording:playback", - "recording:read", - "schedule:read", - "settings:read", - "switcher:read", - ], -}; - -export function hasPermission(role: Role, permission: Permission) { - return rolePermissions[role].includes(permission); -} - -export function hasAnyPermission(role: Role, required: Permission[]) { - return required.some((permission) => hasPermission(role, permission)); -} - -export const auditOutcomeSchema = z.enum(["allowed", "denied", "failed", "partial", "succeeded"]); -export const auditActorTypeSchema = z.enum(["node", "system", "user"]); - -export const currentUserSchema = z.object({ - disabledAt: isoDateTimeSchema.optional(), - email: z.string().email(), - groups: z.array(accessGroupSchema), - id: z.string().min(1), - name: z.string().min(1), - permissions: z.array(permissionSchema), - provider: z.enum(["local", "oidc"]), - resourceGrants: z.array(resourceGrantSchema), - roles: z.array(roleSchema), -}); - -export const auditEventSchema = z.object({ - action: z.string().min(1), - actor: z.object({ - id: z.string().min(1), - name: z.string().min(1), - roles: z.array(roleSchema), - type: auditActorTypeSchema, - }), - actorContext: z.object({ - ipAddress: z.string().optional(), - sessionId: z.string().optional(), - userAgent: z.string().optional(), - }), - after: z.record(z.string(), z.unknown()).optional(), - before: z.record(z.string(), z.unknown()).optional(), - correlationIds: z.record(z.string(), z.string()).optional(), - createdAt: isoDateTimeSchema, - details: z.record(z.string(), z.unknown()), - id: z.string().min(1), - outcome: auditOutcomeSchema, - permission: permissionSchema.optional(), - reason: z.string().optional(), - target: z.object({ - id: z.string().optional(), - name: z.string().optional(), - type: z.string().min(1), - }), -}); - -export const audioChannelSchema = z.object({ - alias: z.string().min(1), - index: z.number().int().positive(), - // Room that owns this channel. Room ownership is per-channel: any set of a - // node's channels can belong to a room, and each channel belongs to at most one - // room. Absent means the channel inherits the node default room. `roomName` is a - // denormalized display copy resolved by the controller. - roomId: z.string().min(1).optional(), - roomName: z.string().min(1).optional(), -}); - -export const audioInterfaceSchema = z.object({ - absent: z.boolean().optional(), // flagged absent by inventory reconcile; see node-store - alias: z.string().min(1), - backend: z.enum(["alsa", "jack", "pipewire", "unknown"]), - channelCount: z.number().int().nonnegative(), - channels: z.array(audioChannelSchema), - hardwarePath: z.string().min(1).optional(), - id: z.string().min(1), - sampleRates: z.array(z.number().int().positive()), - serialNumber: z.string().min(1).optional(), - systemName: z.string().min(1), - systemRef: z.string().min(1).optional(), -}); -export const nodeRuntimeSchema = z.object({ - architecture: z.string().min(1).optional(), - audioBackends: z.array(z.enum(["alsa", "jack", "pipewire", "unknown"])).default([]), - kernelRelease: z.string().min(1).optional(), - osName: z.string().min(1).optional(), - uptimeSeconds: z.number().int().nonnegative().optional(), -}); -export const defaultNodeRecordingCapacity = { maxConcurrentRecordings: 1 } as const; -export const nodeRecordingCapacitySchema = z.object({ - maxConcurrentRecordings: z.number().int().positive().max(128), -}); -export const nodeAudioCommandDefaultsSchema = z.object({ - captureArgsTemplate: z.string().trim().min(1).max(1000).optional(), - captureBackend: audioCaptureBackendSchema.optional(), - captureChannels: z.number().int().positive().max(256).optional(), - captureCommand: z.string().trim().min(1).max(255).optional(), - captureDevice: z.string().trim().min(1).max(255).optional(), - captureFormat: z.string().trim().min(1).max(80).optional(), - captureSampleRate: z.number().int().positive().max(384_000).optional(), - meterArgsTemplate: z.string().trim().min(1).max(1000).optional(), -}); - -export const audioQualitySchema = z.object({ - channelCorrelation: z - .object({ - peerChannelIndex: z.number().int().positive(), - phase: z.enum(["same", "inverted"]), - score: z.number().min(-1).max(1), - }) - .optional(), - broadbandNoiseScore: z.number().min(0).max(1).optional(), - crestFactorDb: z.number().min(0).max(80), - estimatedSnrDb: z.number().min(0).max(80).optional(), - humScore: z.number().min(0).max(1).optional(), - intelligibilityScore: z.number().min(0).max(1).optional(), - noiseScore: z.number().min(0).max(1), - speechLike: z.boolean(), - speechScore: z.number().min(0).max(1), - staticScore: z.number().min(0).max(1).optional(), - zeroCrossingRate: z.number().min(0).max(1), -}); -export const recorderNodeSchema = z.object({ - agentVersion: z.string().min(1), - alias: z.string().min(1), - hostname: z.string().min(1), - id: z.string().min(1), - interfaces: z.array(audioInterfaceSchema), - ipAddresses: z.array(z.string().min(1)), - lastSeenAt: isoDateTimeSchema, - location: z.object({ - building: z.string().optional(), - floor: z.string().optional(), - room: z.string().min(1), - site: z.string().min(1), - }), - // Node default room. Room ownership is per-channel (see audioChannelSchema); - // this is the fallback room for channels with no room of their own, and the - // display fallback for a node whose channels are all in one room. `location` - // above is retained for physical-install display. - roomId: z.string().min(1).optional(), - notes: z.string().optional(), - audioDefaults: nodeAudioCommandDefaultsSchema.optional(), - recordingCapacity: nodeRecordingCapacitySchema.optional(), - runtime: nodeRuntimeSchema.optional(), - status: nodeStatusSchema, - tags: z.array(z.string().min(1)), -}); - -export const audioLevelSchema = z.object({ - channelIndex: z.number().int().positive(), - clipping: z.boolean(), - label: z.string().min(1), - peakDbfs: dbfsSchema, - quality: audioQualitySchema.optional(), - rmsDbfs: dbfsSchema, -}); - -export const meterFrameSchema = z.object({ - capturedAt: isoDateTimeSchema, - interfaceId: z.string().min(1), - // Real interfaces have at most a few dozen channels (X32 = 32). Cap the array - // so a malformed/hostile node frame cannot wedge the watchdog's - // `Math.max(...levels)` spread with a RangeError — which would poison the - // stored `latest` frame and silently disable that node's watchdog. - levels: z.array(audioLevelSchema).max(512), - nodeId: z.string().min(1), -}); - -// Day the operator console's schedule calendar starts its week on. -export const weekStartDaySchema = z.enum([ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -]); -export const controllerSettingsSchema = z.object({ - controllerName: z.string().trim().min(1).max(160), - // Operator-chosen defaults that pre-fill the scheduling and ad-hoc recording - // forms. `null` means "no default set" (the forms fall back to the built-in - // profile/policy). One default per type keeps the "set as default" toggle - // single-select without per-policy flags. - defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().default(null), - defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - weekStartsOn: weekStartDaySchema.default("monday"), -}); -export const controllerSettingsUpdateSchema = z - .object({ - controllerName: z.string().trim().min(1).max(160).optional(), - // Nullable so an operator can clear a default (send `null`); omitted keeps - // the current value. - defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().optional(), - defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - weekStartsOn: weekStartDaySchema.optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one controller setting is required"); -export const defaultControllerSettings = controllerSettingsSchema.parse({ - controllerName: "Rakkr Controller", - weekStartsOn: "monday", -}); -export const channelMapEntrySchema = z.object({ - included: z.boolean(), - label: z.string().trim().min(1).max(160), - outputChannelIndex: z.number().int().positive().optional(), - sourceChannelIndex: z.number().int().positive(), -}); -export const channelMapTemplateSchema = z.object({ - channelMode: channelModeSchema, - entries: z.array(channelMapEntrySchema).min(1).max(128), - id: z.string().min(1), - name: z.string().min(1), - promotedAt: isoDateTimeSchema.optional(), - promotedFromTemplateId: z.string().min(1).optional(), - revision: z.number().int().positive().default(1), - tags: z.array(z.string().min(1)).default([]), -}); -export const channelMapTemplateInputSchema = z.object({ - channelMode: channelModeSchema.default("mono_to_stereo_mix"), - entries: z.array(channelMapEntrySchema).min(1).max(128), - id: z.string().trim().min(1).max(160).optional(), - name: z.string().trim().min(1).max(160), - tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), -}); -export const channelMapTemplateUpdateSchema = z - .object({ - channelMode: channelModeSchema.optional(), - entries: z.array(channelMapEntrySchema).min(1).max(128).optional(), - name: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one channel map field is required"); -export const channelMapAssignmentHistorySchema = z.object({ - actorUserId: z.string().optional(), - changedAt: isoDateTimeSchema, - id: z.string().min(1), - nextTemplateId: z.string().min(1), - previousTemplateId: z.string().min(1).optional(), - reason: z.enum(["assigned", "rolled_back"]), -}); -export const channelMapTemplateAssignmentSchema = z.object({ - assignedAt: isoDateTimeSchema, - history: z.array(channelMapAssignmentHistorySchema).default([]), - id: z.string().min(1), - targetId: z.string().min(1), - targetType: templateAssignmentTargetSchema, - templateId: z.string().min(1), -}); -export const channelMapTemplateAssignmentInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentTargetInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, -}); -export const channelMapTemplateAssignmentBulkInputSchema = z.object({ - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentPlanStatusSchema = z.enum(["applied", "cancelled", "pending"]); -export const channelMapAssignmentPlanInputSchema = z.object({ - note: z.string().trim().max(500).optional(), - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentPlanSchema = z.object({ - appliedAt: isoDateTimeSchema.optional(), - appliedByUserId: z.string().optional(), - cancelledAt: isoDateTimeSchema.optional(), - createdAt: isoDateTimeSchema, - createdByUserId: z.string().optional(), - id: z.string().min(1), - note: z.string().optional(), - status: channelMapAssignmentPlanStatusSchema, - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().min(1), -}); -export const channelMapTemplateAssignmentRollbackInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, -}); -export const scheduleDayOfWeekSchema = z.enum([ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -]); -export const scheduleExceptionSchema = z.discriminatedUnion("action", [ - z.object({ - action: z.literal("skip"), - date: isoDateSchema, - reason: z.string().trim().max(240).optional(), - }), - z - .object({ - action: z.literal("pause"), - endDate: isoDateSchema, - reason: z.string().trim().max(240).optional(), - startDate: isoDateSchema, - }) - .refine((value) => value.startDate <= value.endDate, "Pause start must be before end"), -]); -const scheduleRecurrenceOptions = { - exceptions: z.array(scheduleExceptionSchema).max(366).optional(), - startEarlySeconds: z.number().int().nonnegative().max(86_400).optional(), - stopLateSeconds: z.number().int().nonnegative().max(86_400).optional(), -}; -export const scheduleRecurrenceSchema = z.discriminatedUnion("mode", [ - z.object({ - mode: z.literal("manual"), - ...scheduleRecurrenceOptions, - }), - z.object({ - // Optional fixed recording length (seconds) for a single-fire schedule. - // Set when a timed recurring occurrence is moved into a one-off so the - // moved recording keeps its original duration; absent = open-ended. - durationSeconds: z.number().int().positive().max(2_678_400).optional(), - mode: z.literal("once"), - startsAt: isoDateTimeSchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("daily"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - daysOfWeek: z.array(scheduleDayOfWeekSchema).min(1).max(7), - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("weekly"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - dayOfMonth: z.number().int().min(1).max(31), - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("monthly"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - mode: z.literal("always_on"), - ...scheduleRecurrenceOptions, - }), -]); - -export const scheduleSummarySchema = z.object({ - assignedGroupIds: z.array(accessGroupIdSchema).default([]), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).default([]), - captureBackend: audioCaptureBackendSchema.optional(), - captureChannelSelection: captureChannelSelectionSchema.optional(), - captureInterfaceId: z.string().min(1).optional(), - channelMode: channelModeSchema.optional(), - enabled: z.boolean(), - folderTemplate: z.string().min(1), - id: z.string().min(1), - name: z.string().min(1), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().min(1), - recurrence: scheduleRecurrenceSchema.default({ mode: "manual" }), - recordingProfileId: z.string().min(1), - retentionPolicyId: z.string().min(1).default("retention-keep-controller-cache"), - room: z.string().min(1), - roomId: z.string().min(1).optional(), - tags: z.array(z.string().min(1)), - timezone: z.string().min(1), - titleTemplate: z.string().min(1), - uploadPolicyIds: z.array(z.string().min(1)).default([]), - watchdogPolicyId: z.string().min(1), -}); -export const scheduleInputSchema = z.object({ - assignedGroupIds: z.array(accessGroupIdSchema).max(128).default([]), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).default([]), - captureBackend: audioCaptureBackendSchema.nullable().optional(), - captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), - captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), - channelMode: channelModeSchema.nullable().optional(), - enabled: z.boolean().default(true), - folderTemplate: z.string().trim().min(1).max(500), - id: z.string().trim().min(1).max(160).optional(), - name: z.string().trim().min(1).max(160), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().trim().min(1).max(160), - recurrence: scheduleRecurrenceSchema.optional(), - recordingProfileId: z.string().trim().min(1).max(160), - retentionPolicyId: z.string().trim().min(1).max(160).default("retention-keep-controller-cache"), - room: z.string().trim().min(1).max(160), - roomId: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), - timezone: ianaTimeZoneSchema, - titleTemplate: z.string().trim().min(1).max(500), - // Capped + deduped on write: each recording fans out to one upload queue item - // per id, so an uncapped list multiplies queue work per recording (audit R3-4). - uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).default([]), - watchdogPolicyId: z.string().trim().min(1).max(160), -}); -export const scheduleUpdateSchema = z - .object({ - assignedGroupIds: z.array(accessGroupIdSchema).max(128).optional(), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).optional(), - captureBackend: audioCaptureBackendSchema.nullable().optional(), - captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), - captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), - channelMode: channelModeSchema.nullable().optional(), - enabled: z.boolean().optional(), - folderTemplate: z.string().trim().min(1).max(500).optional(), - name: z.string().trim().min(1).max(160).optional(), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().trim().min(1).max(160).optional(), - recurrence: scheduleRecurrenceSchema.optional(), - recordingProfileId: z.string().trim().min(1).max(160).optional(), - retentionPolicyId: z.string().trim().min(1).max(160).optional(), - room: z.string().trim().min(1).max(160).optional(), - roomId: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), - timezone: ianaTimeZoneSchema.optional(), - titleTemplate: z.string().trim().min(1).max(500).optional(), - uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).optional(), - watchdogPolicyId: z.string().trim().min(1).max(160).optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one schedule field is required"); -export const scheduleOccurrencePreviewSchema = z.object({ - recordingEndAt: isoDateTimeSchema.optional(), - recordingStartAt: isoDateTimeSchema, - scheduledStartAt: isoDateTimeSchema.optional(), -}); -export const scheduleRecurrenceModeSchema = z.enum([ - "manual", - "once", - "daily", - "weekly", - "monthly", - "always_on", -]); -// A single occurrence event returned by the calendar endpoint. Extends the -// occurrence preview with the owning schedule's identity so the calendar can -// render and route interactions (recurrenceMode picks the drag behavior: -// once -> move in place, recurring -> split a single instance). -export const scheduleCalendarOccurrenceSchema = scheduleOccurrencePreviewSchema.extend({ - enabled: z.boolean(), - nodeId: z.string().min(1), - recurrenceMode: scheduleRecurrenceModeSchema, - room: z.string().min(1), - scheduleId: z.string().min(1), - scheduleName: z.string().min(1), - // The schedule's IANA timezone, so the calendar groups each chip on the - // SCHEDULE-local day (matching the backend skip/occurrence day) rather than the - // viewer's browser-local day when the two differ (see calendar-grid groupByLocalDay). - timezone: z.string().min(1).max(80), -}); -export const scheduleCalendarResponseSchema = z.object({ - data: z.array(scheduleCalendarOccurrenceSchema), - meta: z.object({ - end: isoDateTimeSchema, - occurrenceCount: z.number().int().nonnegative(), - scheduleCount: z.number().int().nonnegative(), - start: isoDateTimeSchema, - truncated: z.boolean(), - }), -}); - -export const recordingSummarySchema = z.object({ - cached: z.boolean(), - cachePath: z.string().min(1).optional(), - checksum: z.string().min(1).optional(), - chunks: z.array(recordingChunkSchema).optional(), - chunkSeconds: z.number().int().positive().optional(), - chunkTotal: z.number().int().positive().optional(), - durationSeconds: z.number().int().nonnegative(), - enhancedCachePath: z.string().min(1).optional(), - rawCachePath: z.string().min(1).optional(), - folder: z.string().min(1), - healthStatus: z.enum(["healthy", "warning", "critical", "unknown"]), - id: z.string().min(1), - name: z.string().min(1), - nodeId: z.string().min(1).optional(), - notes: z.string().max(2000).optional(), - recordedAt: isoDateTimeSchema, - recordingProfileId: z.string().min(1).optional(), - retentionPolicyId: z.string().min(1).optional(), - // Room that owns this recording, captured at create time from the selected - // channels' room. Persisted, not derived, so a later channel reassignment does - // not retroactively move a completed recording. - roomId: z.string().min(1).optional(), - scheduleId: z.string().min(1).optional(), - source: recordingSourceSchema, - status: recordingStatusSchema, - tags: z.array(z.string().min(1)), - transcriptSnippets: z.array(z.string().trim().min(1).max(500)).max(20).optional(), - trackGroupId: z.string().min(1).optional(), - trackIndex: z.number().int().positive().optional(), - trackTotal: z.number().int().positive().optional(), - uploadPolicyIds: z.array(z.string().min(1)).optional(), - watchdogPolicyId: z.string().min(1).optional(), - waveformPreview: z - .object({ - channelCount: z.number().int().positive(), - generatedAt: isoDateTimeSchema, - peaks: z.array(z.number().min(0).max(1)).min(1).max(256), - sampleCount: z.number().int().positive(), - sampleRate: z.number().int().positive(), - source: z.enum(["ffmpeg_decoded_peak", "wav_s16le_peak"]), - }) - .optional(), -}); -export const recordingJobChannelMapSchema = z.object({ - assignmentId: z.string().min(1), - channelMode: channelModeSchema, - entries: z.array(channelMapEntrySchema).min(1).max(128), - sourceChannels: z.number().int().positive(), - targetId: z.string().min(1), - targetType: templateAssignmentTargetSchema, - templateId: z.string().min(1), - templateName: z.string().min(1), -}); -export const recordingJobSchema = z.object({ - claimedBy: z.string().min(1).optional(), - command: z.object({ - captureBackend: audioCaptureBackendSchema.optional(), - captureChannels: z.number().int().positive(), - // Resolved 1-based source channels this job owns on the interface. Absent = - // whole interface (legacy). Drives both the channel map and the controller's - // per-channel conflict detection. - captureChannelSelection: captureChannelSelectionSchema.optional(), - captureDevice: z.string().min(1), - captureFormat: z.string().min(1), - // Jobs sharing an interface + capture window carry the same group id so the - // agent can capture the device once and split it into per-job renditions. - captureGroupId: z.string().min(1).optional(), - captureInterfaceId: z.string().min(1).optional(), - captureSampleRate: z.number().int().positive(), - channelMap: recordingJobChannelMapSchema.optional(), - chunkSeconds: z.number().int().positive().optional(), - durationSeconds: z.number().int().positive(), - enhancement: recordingEnhancementSchema.optional(), - outputBitrateKbps: z.number().int().positive().optional(), - outputCodec: z.enum(["mp3", "flac", "wav"]).optional(), - outputFileName: z.string().min(1), - outputVbr: z.boolean().optional(), - recorderCacheRetention: z - .object({ - deleteAfterUpload: z.boolean(), - maxAgeDays: z.number().int().positive().nullable().optional(), - maxBytes: z.number().int().positive().nullable().optional(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), - policyId: z.string().min(1), - }) - .optional(), - trackGroupId: z.string().min(1).optional(), - trackIndex: z.number().int().positive().optional(), - trackTotal: z.number().int().positive().optional(), - type: z.literal("alsa_capture"), - }), - chunks: z.array(recordingChunkSchema).optional(), - chunkTotal: z.number().int().positive().optional(), - completedAt: isoDateTimeSchema.optional(), - createdAt: isoDateTimeSchema, - failureReason: z.string().optional(), - id: z.string().min(1), - lastHeartbeatAt: isoDateTimeSchema.optional(), - leaseExpiresAt: isoDateTimeSchema.optional(), - nodeId: z.string().min(1), - recordingId: z.string().min(1), - startedAt: isoDateTimeSchema.optional(), - status: recordingJobStatusSchema, - stopRequestedAt: isoDateTimeSchema.optional(), -}); -export const uploadQueueItemSchema = z.object({ - attemptCount: z.number().int().nonnegative(), - cachePath: z.string().min(1).optional(), - checksum: z.string().min(1).optional(), - // Set when this item uploads one recording chunk as its own object. NULL = - // legacy whole-recording item. - chunkId: z.string().min(1).optional(), - chunkIndex: z.number().int().positive().optional(), - createdAt: isoDateTimeSchema, - destinationId: z.string().min(1).optional(), - fileName: z.string().min(1).optional(), - id: z.string().min(1), - lastError: z.string().min(1).optional(), - maxAttempts: z.number().int().positive(), - nextAttemptAt: isoDateTimeSchema, - pathOverride: z.string().min(1).optional(), - provider: uploadProviderSchema, - recordingId: z.string().min(1), - status: uploadQueueStatusSchema, - target: z.string().min(1).optional(), - updatedAt: isoDateTimeSchema, - uploadPolicyId: z.string().min(1).optional(), -}); -export const uploadChecksumVerificationSchema = z.object({ - algorithm: z.literal("sha256"), - expected: z.string().min(1), - method: z.enum(["file_copy_sha256", "s3_checksum_sha256"]), - observed: z.string().min(1).optional(), - // `provider_declared` is the honest status for S3-compatible custom endpoints - // that may ignore the trailing ChecksumSHA256; only real AWS S3 validates it, - // which is `provider_validated`. `matched` is our own read-back byte compare. - status: z.enum(["matched", "provider_validated", "provider_declared"]), -}); -export const uploadQueueRunItemSchema = z.object({ - checksumVerification: uploadChecksumVerificationSchema.optional(), - itemId: z.string().min(1), - provider: uploadProviderSchema, - reason: z.string().min(1).optional(), - recordingId: z.string().min(1), - status: uploadQueueStatusSchema, -}); -export const uploadQueueRunSummarySchema = z.object({ - attempted: z.number().int().nonnegative(), - deferred: z.number().int().nonnegative(), - failed: z.number().int().nonnegative(), - items: z.array(uploadQueueRunItemSchema), - succeeded: z.number().int().nonnegative(), -}); -export const uploadRunnerStatusSchema = z.object({ - batchSize: z.number().int().positive(), - intervalSeconds: z.number().int().positive(), - lastRunAt: isoDateTimeSchema.optional(), - lastSummary: uploadQueueRunSummarySchema.optional(), - running: z.boolean(), - started: z.boolean(), -}); - -export const uploadPolicyTriggerSchema = z.enum(["manual", "on_recording_cached"]); -export const uploadPolicySchema = z.object({ - deleteCacheAfterUpload: z.boolean().default(false), - destinationId: z.string().min(1).optional(), - enabled: z.boolean(), - id: z.string().min(1), - maxAttempts: z.number().int().positive().max(100), - name: z.string().min(1), - pathOverride: z.string().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema, - updatedAt: isoDateTimeSchema, -}); -export const uploadPolicyInputSchema = z.object({ - deleteCacheAfterUpload: z.boolean().default(false), - // Optional in the shared shape (the store is an internal seeding primitive), - // but the operator create route requires it — every policy must target a real - // destination or its recordings reconcile to `partial` (audit H3-3). - destinationId: z.string().trim().min(1).max(160).optional(), - enabled: z.boolean().default(true), - id: z.string().trim().min(1).max(160).optional(), - maxAttempts: z.number().int().positive().max(100).default(5), - name: z.string().trim().min(1).max(160), - pathOverride: z.string().trim().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema.default("manual"), -}); -export const uploadPolicyUpdateSchema = z - .object({ - deleteCacheAfterUpload: z.boolean().optional(), - destinationId: z.string().trim().min(1).max(160).optional(), - enabled: z.boolean().optional(), - maxAttempts: z.number().int().positive().max(100).optional(), - name: z.string().trim().min(1).max(160).optional(), - pathOverride: z.string().trim().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema.optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one upload policy field is required"); - -export const retentionPolicyScopeSchema = z.enum(["controller_cache", "recorder_cache"]); -export const retentionPolicyActionSchema = z.enum(["keep", "delete_cache"]); -export const retentionPolicySchema = z.object({ - action: retentionPolicyActionSchema, - deleteOnlyAfterUploaded: z.boolean(), - enabled: z.boolean(), - id: z.string().min(1), - maxAgeDays: z.number().int().positive().max(3650).nullable(), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable(), - name: z.string().min(1), - preserveTagged: z.boolean(), - scope: retentionPolicyScopeSchema, - updatedAt: isoDateTimeSchema, -}); -export const retentionPolicyInputSchema = z.object({ - action: retentionPolicyActionSchema.default("keep"), - deleteOnlyAfterUploaded: z.boolean().default(true), - enabled: z.boolean().default(true), - id: z.string().trim().min(1).max(160).optional(), - maxAgeDays: z.number().int().positive().max(3650).nullable().default(null), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().default(null), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().default(null), - name: z.string().trim().min(1).max(160), - preserveTagged: z.boolean().default(true), - scope: retentionPolicyScopeSchema.default("controller_cache"), -}); -export const retentionPolicyUpdateSchema = z - .object({ - action: retentionPolicyActionSchema.optional(), - deleteOnlyAfterUploaded: z.boolean().optional(), - enabled: z.boolean().optional(), - maxAgeDays: z.number().int().positive().max(3650).nullable().optional(), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().optional(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), - name: z.string().trim().min(1).max(160).optional(), - preserveTagged: z.boolean().optional(), - scope: retentionPolicyScopeSchema.optional(), - }) - .refine( - (value) => Object.keys(value).length > 0, - "At least one retention policy field is required", - ); - -export const healthEventSchema = z.object({ - acknowledgedAt: isoDateTimeSchema.nullable(), - acknowledgedBy: z.string().optional(), - details: z.record(z.string(), z.unknown()), - id: z.string().min(1), - nodeId: z.string().min(1).optional(), - openedAt: isoDateTimeSchema, - recordingId: z.string().optional(), - resolvedAt: isoDateTimeSchema.nullable(), - resolvedBy: z.string().optional(), - scheduleId: z.string().optional(), - severity: healthSeveritySchema, - status: healthEventStatusSchema, - suppressedAt: isoDateTimeSchema.nullable(), - suppressedBy: z.string().optional(), - suppressedUntil: isoDateTimeSchema.nullable(), - type: z.string().min(1), -}); - -export const defaultStubUploadPolicy = { - deleteCacheAfterUpload: false, - enabled: true, - id: "upload-policy-stub", - maxAttempts: 5, - name: "Stub Upload Queue", - trigger: "manual", - updatedAt: "1970-01-01T00:00:00.000Z", -} satisfies UploadPolicy; - -export const defaultKeepControllerCacheRetentionPolicy = { - action: "keep", - deleteOnlyAfterUploaded: true, - enabled: true, - id: "retention-keep-controller-cache", - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: null, - name: "Keep Controller Cache", - preserveTagged: true, - scope: "controller_cache", - updatedAt: "1970-01-01T00:00:00.000Z", -} satisfies RetentionPolicy; - -export type AudioChannel = z.infer; -export type ChannelMapAssignmentHistory = z.infer; -export type ChannelMapEntry = z.infer; -export type ChannelMapTemplate = z.infer; -export type ChannelMapTemplateAssignment = z.infer; -export type ChannelMapTemplateAssignmentInput = z.infer< - typeof channelMapTemplateAssignmentInputSchema ->; -export type ChannelMapTemplateAssignmentBulkInput = z.infer< - typeof channelMapTemplateAssignmentBulkInputSchema ->; -export type ChannelMapAssignmentPlan = z.infer; -export type ChannelMapAssignmentPlanInput = z.infer; -export type ChannelMapTemplateAssignmentRollbackInput = z.infer< - typeof channelMapTemplateAssignmentRollbackInputSchema ->; -export type ChannelMapTemplateInput = z.infer; -export type ChannelMapTemplateUpdate = z.infer; -export type AuditActorType = z.infer; -export type AuditEvent = z.infer; -export type AuditOutcome = z.infer; -export type AudioInterface = z.infer; -export type AccessPolicy = z.infer; -export type AccessPolicyEffect = z.infer; -export type AccessPolicyInput = z.infer; -export type AccessPolicySubjectType = z.infer; -export type AudioLevel = z.infer; -export type AudioQuality = z.infer; -export type CurrentUser = z.infer; -export type HealthEvent = z.infer; -export type HealthEventStatus = z.infer; -export type HealthSeverity = z.infer; -export type MeterFrame = z.infer; -export type NodeStatus = z.infer; -export type NodeAudioCommandDefaults = z.infer; -export type NodeRecordingCapacity = z.infer; -export type NodeRuntime = z.infer; -export type RecorderNode = z.infer; -export type ControllerSettings = z.infer; -export type ControllerSettingsUpdate = z.infer; -export type WeekStartDay = z.infer; -export type RecordingJob = z.infer; -export type RecordingJobChannelMap = z.infer; -export type RecordingJobStatus = z.infer; -export type RecordingSummary = z.infer; -export type RecordingWaveformPreview = NonNullable; -export type RetentionPolicy = z.infer; -export type RetentionPolicyAction = z.infer; -export type RetentionPolicyInput = z.infer; -export type RetentionPolicyScope = z.infer; -export type RetentionPolicyUpdate = z.infer; -export type ResourceGrant = z.infer; -export type ScheduleCalendarOccurrence = z.infer; -export type ScheduleCalendarResponse = z.infer; -export type ScheduleDayOfWeek = z.infer; -export type ScheduleException = z.infer; -export type ScheduleInput = z.infer; -export type ScheduleOccurrencePreview = z.infer; -export type ScheduleRecurrenceMode = z.infer; -export type ScheduleRecurrence = z.infer; -export type ScheduleSummary = z.infer; -export type ScheduleUpdate = z.infer; -export type UploadProvider = z.infer; -export type UploadPolicy = z.infer; -export type UploadPolicyInput = z.infer; -export type UploadPolicyTrigger = z.infer; -export type UploadPolicyUpdate = z.infer; -export type UploadChecksumVerification = z.infer; -export type UploadQueueItem = z.infer; -export type UploadQueueRunItem = z.infer; -export type UploadQueueRunSummary = z.infer; -export type UploadQueueStatus = z.infer; -export type UploadRunnerStatus = z.infer; diff --git a/packages/shared/src/nodes.ts b/packages/shared/src/nodes.ts new file mode 100644 index 00000000..28473a7f --- /dev/null +++ b/packages/shared/src/nodes.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import { audioCaptureBackendSchema, dbfsSchema, isoDateTimeSchema } from "./base.js"; + +// "provisioning" is an enrolled node that has never made contact (no bootstrap, +// no heartbeat). It is excluded from liveness/offline monitoring until its first +// heartbeat flips it to a live status — see node-liveness / watchdog-node-liveness. +export const nodeStatusSchema = z.enum([ + "provisioning", + "online", + "offline", + "degraded", + "recording", + "alerting", +]); +export type NodeStatus = z.infer; + +// A node is "reachable" (in contact and reporting) when its status is online, +// recording, degraded, or alerting. "offline" (heartbeat gone stale) and +// "provisioning" (enrolled but never contacted) are NOT reachable. Shared by the +// /metrics `rakkr_node_online` gauge and the dashboard active-node count so the +// two cannot diverge on how a never-contacted provisioning node is treated +// (a naive `status !== "offline"` counts provisioning as online — see audit N1/N2). +export function isNodeReachable(status: NodeStatus): boolean { + return ( + status === "online" || status === "recording" || status === "degraded" || status === "alerting" + ); +} + +export const audioChannelSchema = z.object({ + alias: z.string().min(1), + index: z.number().int().positive(), + // Room that owns this channel. Room ownership is per-channel: any set of a + // node's channels can belong to a room, and each channel belongs to at most one + // room. Absent means the channel inherits the node default room. `roomName` is a + // denormalized display copy resolved by the controller. + roomId: z.string().min(1).optional(), + roomName: z.string().min(1).optional(), +}); + +export const audioInterfaceSchema = z.object({ + absent: z.boolean().optional(), // flagged absent by inventory reconcile; see node-store + alias: z.string().min(1), + backend: z.enum(["alsa", "jack", "pipewire", "unknown"]), + channelCount: z.number().int().nonnegative(), + channels: z.array(audioChannelSchema), + hardwarePath: z.string().min(1).optional(), + id: z.string().min(1), + sampleRates: z.array(z.number().int().positive()), + serialNumber: z.string().min(1).optional(), + systemName: z.string().min(1), + systemRef: z.string().min(1).optional(), +}); +export const nodeRuntimeSchema = z.object({ + architecture: z.string().min(1).optional(), + audioBackends: z.array(z.enum(["alsa", "jack", "pipewire", "unknown"])).default([]), + kernelRelease: z.string().min(1).optional(), + osName: z.string().min(1).optional(), + uptimeSeconds: z.number().int().nonnegative().optional(), +}); +export const defaultNodeRecordingCapacity = { maxConcurrentRecordings: 1 } as const; +export const nodeRecordingCapacitySchema = z.object({ + maxConcurrentRecordings: z.number().int().positive().max(128), +}); +export const nodeAudioCommandDefaultsSchema = z.object({ + captureArgsTemplate: z.string().trim().min(1).max(1000).optional(), + captureBackend: audioCaptureBackendSchema.optional(), + captureChannels: z.number().int().positive().max(256).optional(), + captureCommand: z.string().trim().min(1).max(255).optional(), + captureDevice: z.string().trim().min(1).max(255).optional(), + captureFormat: z.string().trim().min(1).max(80).optional(), + captureSampleRate: z.number().int().positive().max(384_000).optional(), + meterArgsTemplate: z.string().trim().min(1).max(1000).optional(), +}); + +export const audioQualitySchema = z.object({ + channelCorrelation: z + .object({ + peerChannelIndex: z.number().int().positive(), + phase: z.enum(["same", "inverted"]), + score: z.number().min(-1).max(1), + }) + .optional(), + broadbandNoiseScore: z.number().min(0).max(1).optional(), + crestFactorDb: z.number().min(0).max(80), + estimatedSnrDb: z.number().min(0).max(80).optional(), + humScore: z.number().min(0).max(1).optional(), + intelligibilityScore: z.number().min(0).max(1).optional(), + noiseScore: z.number().min(0).max(1), + speechLike: z.boolean(), + speechScore: z.number().min(0).max(1), + staticScore: z.number().min(0).max(1).optional(), + zeroCrossingRate: z.number().min(0).max(1), +}); +export const recorderNodeSchema = z.object({ + agentVersion: z.string().min(1), + alias: z.string().min(1), + hostname: z.string().min(1), + id: z.string().min(1), + interfaces: z.array(audioInterfaceSchema), + ipAddresses: z.array(z.string().min(1)), + lastSeenAt: isoDateTimeSchema, + location: z.object({ + building: z.string().optional(), + floor: z.string().optional(), + room: z.string().min(1), + site: z.string().min(1), + }), + // Node default room. Room ownership is per-channel (see audioChannelSchema); + // this is the fallback room for channels with no room of their own, and the + // display fallback for a node whose channels are all in one room. `location` + // above is retained for physical-install display. + roomId: z.string().min(1).optional(), + notes: z.string().optional(), + audioDefaults: nodeAudioCommandDefaultsSchema.optional(), + recordingCapacity: nodeRecordingCapacitySchema.optional(), + runtime: nodeRuntimeSchema.optional(), + status: nodeStatusSchema, + tags: z.array(z.string().min(1)), +}); + +export const audioLevelSchema = z.object({ + channelIndex: z.number().int().positive(), + clipping: z.boolean(), + label: z.string().min(1), + peakDbfs: dbfsSchema, + quality: audioQualitySchema.optional(), + rmsDbfs: dbfsSchema, +}); + +export const meterFrameSchema = z.object({ + capturedAt: isoDateTimeSchema, + interfaceId: z.string().min(1), + // Real interfaces have at most a few dozen channels (X32 = 32). Cap the array + // so a malformed/hostile node frame cannot wedge the watchdog's + // `Math.max(...levels)` spread with a RangeError — which would poison the + // stored `latest` frame and silently disable that node's watchdog. + levels: z.array(audioLevelSchema).max(512), + nodeId: z.string().min(1), +}); + +export type AudioChannel = z.infer; +export type AudioInterface = z.infer; +export type AudioLevel = z.infer; +export type AudioQuality = z.infer; +export type MeterFrame = z.infer; +export type NodeAudioCommandDefaults = z.infer; +export type NodeRecordingCapacity = z.infer; +export type NodeRuntime = z.infer; +export type RecorderNode = z.infer; diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts new file mode 100644 index 00000000..2599c09e --- /dev/null +++ b/packages/shared/src/rbac.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { accessGroupSchema } from "./access-groups.js"; +import { isoDateTimeSchema } from "./base.js"; + +// Role/permission catalog and the derived allow decisions. This is the RBAC +// source of truth shared by the controller API and the operator console. +export const permissions = [ + "audit:read", + "auth:manage", + "health:acknowledge", + "health:read", + "listen:monitor", + "metrics:read", + "node:control", + "node:manage", + "node:read", + "recording:control", + "recording:create", + "recording:delete", + "recording:download", + "recording:edit", + "recording:playback", + "recording:read", + "schedule:manage", + "schedule:read", + "settings:manage", + "settings:read", + "switcher:manage", + "switcher:map", + "switcher:read", + "system:admin", +] as const; + +export const roles = ["owner", "admin", "operator", "viewer", "auditor"] as const; +export type Permission = (typeof permissions)[number]; +export type Role = (typeof roles)[number]; +export const permissionSchema = z.enum(permissions); +export const roleSchema = z.enum(roles); +export const accessPolicyEffectSchema = z.enum(["allow", "deny"]); +export const accessPolicySubjectTypeSchema = z.enum(["user", "group", "everyone"]); +export const resourceGrantSchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), +}); +export const accessPolicySchema = z.object({ + effect: accessPolicyEffectSchema, + id: z.string().min(1), + reason: z.string().optional(), + resourceId: z.string().min(1), + resourceType: z.string().min(1), + subjectId: z.string().optional(), + subjectType: accessPolicySubjectTypeSchema, +}); +export const accessPolicyInputSchema = accessPolicySchema.omit({ id: true }); +export const rolePermissions: Record = { + admin: permissions.filter((permission) => permission !== "system:admin"), + auditor: ["audit:read", "health:read", "metrics:read", "recording:read"], + operator: [ + "health:acknowledge", + "health:read", + "listen:monitor", + "metrics:read", + "node:control", + "node:read", + "recording:control", + "recording:create", + "recording:download", + "recording:edit", + "recording:playback", + "recording:read", + "schedule:manage", + "schedule:read", + "settings:read", + "switcher:map", + "switcher:read", + ], + owner: permissions, + viewer: [ + "health:read", + "metrics:read", + "node:read", + "recording:download", + "recording:playback", + "recording:read", + "schedule:read", + "settings:read", + "switcher:read", + ], +}; + +export function hasPermission(role: Role, permission: Permission) { + return rolePermissions[role].includes(permission); +} + +export function hasAnyPermission(role: Role, required: Permission[]) { + return required.some((permission) => hasPermission(role, permission)); +} + +export const currentUserSchema = z.object({ + disabledAt: isoDateTimeSchema.optional(), + email: z.string().email(), + groups: z.array(accessGroupSchema), + id: z.string().min(1), + name: z.string().min(1), + permissions: z.array(permissionSchema), + provider: z.enum(["local", "oidc"]), + resourceGrants: z.array(resourceGrantSchema), + roles: z.array(roleSchema), +}); + +export type AccessPolicy = z.infer; +export type AccessPolicyEffect = z.infer; +export type AccessPolicyInput = z.infer; +export type AccessPolicySubjectType = z.infer; +export type CurrentUser = z.infer; +export type ResourceGrant = z.infer; diff --git a/packages/shared/src/recordings.ts b/packages/shared/src/recordings.ts new file mode 100644 index 00000000..328f7174 --- /dev/null +++ b/packages/shared/src/recordings.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; + +import { audioCaptureBackendSchema, isoDateTimeSchema } from "./base.js"; +import { channelMapEntrySchema, templateAssignmentTargetSchema } from "./channel-maps.js"; +import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; +import { recordingEnhancementSchema } from "./enhancement.js"; +import { recordingChunkSchema } from "./recording-chunks.js"; + +export const recordingSourceSchema = z.enum(["ad_hoc", "schedule"]); +export const recordingStatusSchema = z.enum([ + "queued", + "recording", + "completed", + "failed", + "cached", + "uploaded", + "partial", +]); +export const recordingJobStatusSchema = z.enum([ + "queued", + "running", + "stop_requested", + "cancelled", + "completed", + "failed", +]); + +export const recordingSummarySchema = z.object({ + cached: z.boolean(), + cachePath: z.string().min(1).optional(), + checksum: z.string().min(1).optional(), + chunks: z.array(recordingChunkSchema).optional(), + chunkSeconds: z.number().int().positive().optional(), + chunkTotal: z.number().int().positive().optional(), + durationSeconds: z.number().int().nonnegative(), + enhancedCachePath: z.string().min(1).optional(), + rawCachePath: z.string().min(1).optional(), + folder: z.string().min(1), + healthStatus: z.enum(["healthy", "warning", "critical", "unknown"]), + id: z.string().min(1), + name: z.string().min(1), + nodeId: z.string().min(1).optional(), + notes: z.string().max(2000).optional(), + recordedAt: isoDateTimeSchema, + recordingProfileId: z.string().min(1).optional(), + retentionPolicyId: z.string().min(1).optional(), + // Room that owns this recording, captured at create time from the selected + // channels' room. Persisted, not derived, so a later channel reassignment does + // not retroactively move a completed recording. + roomId: z.string().min(1).optional(), + scheduleId: z.string().min(1).optional(), + source: recordingSourceSchema, + status: recordingStatusSchema, + tags: z.array(z.string().min(1)), + transcriptSnippets: z.array(z.string().trim().min(1).max(500)).max(20).optional(), + trackGroupId: z.string().min(1).optional(), + trackIndex: z.number().int().positive().optional(), + trackTotal: z.number().int().positive().optional(), + uploadPolicyIds: z.array(z.string().min(1)).optional(), + watchdogPolicyId: z.string().min(1).optional(), + waveformPreview: z + .object({ + channelCount: z.number().int().positive(), + generatedAt: isoDateTimeSchema, + peaks: z.array(z.number().min(0).max(1)).min(1).max(256), + sampleCount: z.number().int().positive(), + sampleRate: z.number().int().positive(), + source: z.enum(["ffmpeg_decoded_peak", "wav_s16le_peak"]), + }) + .optional(), +}); +export const recordingJobChannelMapSchema = z.object({ + assignmentId: z.string().min(1), + channelMode: channelModeSchema, + entries: z.array(channelMapEntrySchema).min(1).max(128), + sourceChannels: z.number().int().positive(), + targetId: z.string().min(1), + targetType: templateAssignmentTargetSchema, + templateId: z.string().min(1), + templateName: z.string().min(1), +}); +export const recordingJobSchema = z.object({ + claimedBy: z.string().min(1).optional(), + command: z.object({ + captureBackend: audioCaptureBackendSchema.optional(), + captureChannels: z.number().int().positive(), + // Resolved 1-based source channels this job owns on the interface. Absent = + // whole interface (legacy). Drives both the channel map and the controller's + // per-channel conflict detection. + captureChannelSelection: captureChannelSelectionSchema.optional(), + captureDevice: z.string().min(1), + captureFormat: z.string().min(1), + // Jobs sharing an interface + capture window carry the same group id so the + // agent can capture the device once and split it into per-job renditions. + captureGroupId: z.string().min(1).optional(), + captureInterfaceId: z.string().min(1).optional(), + captureSampleRate: z.number().int().positive(), + channelMap: recordingJobChannelMapSchema.optional(), + chunkSeconds: z.number().int().positive().optional(), + durationSeconds: z.number().int().positive(), + enhancement: recordingEnhancementSchema.optional(), + outputBitrateKbps: z.number().int().positive().optional(), + outputCodec: z.enum(["mp3", "flac", "wav"]).optional(), + outputFileName: z.string().min(1), + outputVbr: z.boolean().optional(), + recorderCacheRetention: z + .object({ + deleteAfterUpload: z.boolean(), + maxAgeDays: z.number().int().positive().nullable().optional(), + maxBytes: z.number().int().positive().nullable().optional(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), + policyId: z.string().min(1), + }) + .optional(), + trackGroupId: z.string().min(1).optional(), + trackIndex: z.number().int().positive().optional(), + trackTotal: z.number().int().positive().optional(), + type: z.literal("alsa_capture"), + }), + chunks: z.array(recordingChunkSchema).optional(), + chunkTotal: z.number().int().positive().optional(), + completedAt: isoDateTimeSchema.optional(), + createdAt: isoDateTimeSchema, + failureReason: z.string().optional(), + id: z.string().min(1), + lastHeartbeatAt: isoDateTimeSchema.optional(), + leaseExpiresAt: isoDateTimeSchema.optional(), + nodeId: z.string().min(1), + recordingId: z.string().min(1), + startedAt: isoDateTimeSchema.optional(), + status: recordingJobStatusSchema, + stopRequestedAt: isoDateTimeSchema.optional(), +}); + +export type RecordingJob = z.infer; +export type RecordingJobChannelMap = z.infer; +export type RecordingJobStatus = z.infer; +export type RecordingSummary = z.infer; +export type RecordingWaveformPreview = NonNullable; diff --git a/packages/shared/src/retention.ts b/packages/shared/src/retention.ts new file mode 100644 index 00000000..535e8762 --- /dev/null +++ b/packages/shared/src/retention.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; + +export const retentionPolicyScopeSchema = z.enum(["controller_cache", "recorder_cache"]); +export const retentionPolicyActionSchema = z.enum(["keep", "delete_cache"]); +export const retentionPolicySchema = z.object({ + action: retentionPolicyActionSchema, + deleteOnlyAfterUploaded: z.boolean(), + enabled: z.boolean(), + id: z.string().min(1), + maxAgeDays: z.number().int().positive().max(3650).nullable(), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable(), + name: z.string().min(1), + preserveTagged: z.boolean(), + scope: retentionPolicyScopeSchema, + updatedAt: isoDateTimeSchema, +}); +export const retentionPolicyInputSchema = z.object({ + action: retentionPolicyActionSchema.default("keep"), + deleteOnlyAfterUploaded: z.boolean().default(true), + enabled: z.boolean().default(true), + id: z.string().trim().min(1).max(160).optional(), + maxAgeDays: z.number().int().positive().max(3650).nullable().default(null), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().default(null), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().default(null), + name: z.string().trim().min(1).max(160), + preserveTagged: z.boolean().default(true), + scope: retentionPolicyScopeSchema.default("controller_cache"), +}); +export const retentionPolicyUpdateSchema = z + .object({ + action: retentionPolicyActionSchema.optional(), + deleteOnlyAfterUploaded: z.boolean().optional(), + enabled: z.boolean().optional(), + maxAgeDays: z.number().int().positive().max(3650).nullable().optional(), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().optional(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), + name: z.string().trim().min(1).max(160).optional(), + preserveTagged: z.boolean().optional(), + scope: retentionPolicyScopeSchema.optional(), + }) + .refine( + (value) => Object.keys(value).length > 0, + "At least one retention policy field is required", + ); + +export const defaultKeepControllerCacheRetentionPolicy = { + action: "keep", + deleteOnlyAfterUploaded: true, + enabled: true, + id: "retention-keep-controller-cache", + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: null, + name: "Keep Controller Cache", + preserveTagged: true, + scope: "controller_cache", + updatedAt: "1970-01-01T00:00:00.000Z", +} satisfies RetentionPolicy; + +export type RetentionPolicy = z.infer; +export type RetentionPolicyAction = z.infer; +export type RetentionPolicyInput = z.infer; +export type RetentionPolicyScope = z.infer; +export type RetentionPolicyUpdate = z.infer; diff --git a/packages/shared/src/room-capabilities.ts b/packages/shared/src/room-capabilities.ts index c6266f6f..fdd19190 100644 --- a/packages/shared/src/room-capabilities.ts +++ b/packages/shared/src/room-capabilities.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { Permission } from "./index.js"; +import type { Permission } from "./rbac.js"; // The independently-grantable per-action room capabilities. A room roster entry // carries any subset of these; effective access is the union across a subject's diff --git a/packages/shared/src/schedules.ts b/packages/shared/src/schedules.ts new file mode 100644 index 00000000..0c9ec0e3 --- /dev/null +++ b/packages/shared/src/schedules.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; + +import { accessGroupIdSchema } from "./access-groups.js"; +import { + audioCaptureBackendSchema, + ianaTimeZoneSchema, + isoDateSchema, + isoDateTimeSchema, + timeOfDaySchema, +} from "./base.js"; +import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; + +export const scheduleDayOfWeekSchema = z.enum([ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +]); +export const scheduleExceptionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("skip"), + date: isoDateSchema, + reason: z.string().trim().max(240).optional(), + }), + z + .object({ + action: z.literal("pause"), + endDate: isoDateSchema, + reason: z.string().trim().max(240).optional(), + startDate: isoDateSchema, + }) + .refine((value) => value.startDate <= value.endDate, "Pause start must be before end"), +]); +const scheduleRecurrenceOptions = { + exceptions: z.array(scheduleExceptionSchema).max(366).optional(), + startEarlySeconds: z.number().int().nonnegative().max(86_400).optional(), + stopLateSeconds: z.number().int().nonnegative().max(86_400).optional(), +}; +export const scheduleRecurrenceSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("manual"), + ...scheduleRecurrenceOptions, + }), + z.object({ + // Optional fixed recording length (seconds) for a single-fire schedule. + // Set when a timed recurring occurrence is moved into a one-off so the + // moved recording keeps its original duration; absent = open-ended. + durationSeconds: z.number().int().positive().max(2_678_400).optional(), + mode: z.literal("once"), + startsAt: isoDateTimeSchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("daily"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + daysOfWeek: z.array(scheduleDayOfWeekSchema).min(1).max(7), + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("weekly"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + dayOfMonth: z.number().int().min(1).max(31), + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("monthly"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + mode: z.literal("always_on"), + ...scheduleRecurrenceOptions, + }), +]); + +export const scheduleSummarySchema = z.object({ + assignedGroupIds: z.array(accessGroupIdSchema).default([]), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).default([]), + captureBackend: audioCaptureBackendSchema.optional(), + captureChannelSelection: captureChannelSelectionSchema.optional(), + captureInterfaceId: z.string().min(1).optional(), + channelMode: channelModeSchema.optional(), + enabled: z.boolean(), + folderTemplate: z.string().min(1), + id: z.string().min(1), + name: z.string().min(1), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().min(1), + recurrence: scheduleRecurrenceSchema.default({ mode: "manual" }), + recordingProfileId: z.string().min(1), + retentionPolicyId: z.string().min(1).default("retention-keep-controller-cache"), + room: z.string().min(1), + roomId: z.string().min(1).optional(), + tags: z.array(z.string().min(1)), + timezone: z.string().min(1), + titleTemplate: z.string().min(1), + uploadPolicyIds: z.array(z.string().min(1)).default([]), + watchdogPolicyId: z.string().min(1), +}); +export const scheduleInputSchema = z.object({ + assignedGroupIds: z.array(accessGroupIdSchema).max(128).default([]), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).default([]), + captureBackend: audioCaptureBackendSchema.nullable().optional(), + captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), + captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), + channelMode: channelModeSchema.nullable().optional(), + enabled: z.boolean().default(true), + folderTemplate: z.string().trim().min(1).max(500), + id: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(160), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().trim().min(1).max(160), + recurrence: scheduleRecurrenceSchema.optional(), + recordingProfileId: z.string().trim().min(1).max(160), + retentionPolicyId: z.string().trim().min(1).max(160).default("retention-keep-controller-cache"), + room: z.string().trim().min(1).max(160), + roomId: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), + timezone: ianaTimeZoneSchema, + titleTemplate: z.string().trim().min(1).max(500), + // Capped + deduped on write: each recording fans out to one upload queue item + // per id, so an uncapped list multiplies queue work per recording (audit R3-4). + uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).default([]), + watchdogPolicyId: z.string().trim().min(1).max(160), +}); +export const scheduleUpdateSchema = z + .object({ + assignedGroupIds: z.array(accessGroupIdSchema).max(128).optional(), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).optional(), + captureBackend: audioCaptureBackendSchema.nullable().optional(), + captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), + captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), + channelMode: channelModeSchema.nullable().optional(), + enabled: z.boolean().optional(), + folderTemplate: z.string().trim().min(1).max(500).optional(), + name: z.string().trim().min(1).max(160).optional(), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().trim().min(1).max(160).optional(), + recurrence: scheduleRecurrenceSchema.optional(), + recordingProfileId: z.string().trim().min(1).max(160).optional(), + retentionPolicyId: z.string().trim().min(1).max(160).optional(), + room: z.string().trim().min(1).max(160).optional(), + roomId: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), + timezone: ianaTimeZoneSchema.optional(), + titleTemplate: z.string().trim().min(1).max(500).optional(), + uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).optional(), + watchdogPolicyId: z.string().trim().min(1).max(160).optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one schedule field is required"); +export const scheduleOccurrencePreviewSchema = z.object({ + recordingEndAt: isoDateTimeSchema.optional(), + recordingStartAt: isoDateTimeSchema, + scheduledStartAt: isoDateTimeSchema.optional(), +}); +export const scheduleRecurrenceModeSchema = z.enum([ + "manual", + "once", + "daily", + "weekly", + "monthly", + "always_on", +]); +// A single occurrence event returned by the calendar endpoint. Extends the +// occurrence preview with the owning schedule's identity so the calendar can +// render and route interactions (recurrenceMode picks the drag behavior: +// once -> move in place, recurring -> split a single instance). +export const scheduleCalendarOccurrenceSchema = scheduleOccurrencePreviewSchema.extend({ + enabled: z.boolean(), + nodeId: z.string().min(1), + recurrenceMode: scheduleRecurrenceModeSchema, + room: z.string().min(1), + scheduleId: z.string().min(1), + scheduleName: z.string().min(1), + // The schedule's IANA timezone, so the calendar groups each chip on the + // SCHEDULE-local day (matching the backend skip/occurrence day) rather than the + // viewer's browser-local day when the two differ (see calendar-grid groupByLocalDay). + timezone: z.string().min(1).max(80), +}); +export const scheduleCalendarResponseSchema = z.object({ + data: z.array(scheduleCalendarOccurrenceSchema), + meta: z.object({ + end: isoDateTimeSchema, + occurrenceCount: z.number().int().nonnegative(), + scheduleCount: z.number().int().nonnegative(), + start: isoDateTimeSchema, + truncated: z.boolean(), + }), +}); + +export type ScheduleCalendarOccurrence = z.infer; +export type ScheduleCalendarResponse = z.infer; +export type ScheduleDayOfWeek = z.infer; +export type ScheduleException = z.infer; +export type ScheduleInput = z.infer; +export type ScheduleOccurrencePreview = z.infer; +export type ScheduleRecurrence = z.infer; +export type ScheduleRecurrenceMode = z.infer; +export type ScheduleSummary = z.infer; +export type ScheduleUpdate = z.infer; diff --git a/packages/shared/src/uploads.ts b/packages/shared/src/uploads.ts new file mode 100644 index 00000000..d664622e --- /dev/null +++ b/packages/shared/src/uploads.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { isoDateTimeSchema, uploadProviderSchema, uploadQueueStatusSchema } from "./base.js"; + +export const uploadQueueItemSchema = z.object({ + attemptCount: z.number().int().nonnegative(), + cachePath: z.string().min(1).optional(), + checksum: z.string().min(1).optional(), + // Set when this item uploads one recording chunk as its own object. NULL = + // legacy whole-recording item. + chunkId: z.string().min(1).optional(), + chunkIndex: z.number().int().positive().optional(), + createdAt: isoDateTimeSchema, + destinationId: z.string().min(1).optional(), + fileName: z.string().min(1).optional(), + id: z.string().min(1), + lastError: z.string().min(1).optional(), + maxAttempts: z.number().int().positive(), + nextAttemptAt: isoDateTimeSchema, + pathOverride: z.string().min(1).optional(), + provider: uploadProviderSchema, + recordingId: z.string().min(1), + status: uploadQueueStatusSchema, + target: z.string().min(1).optional(), + updatedAt: isoDateTimeSchema, + uploadPolicyId: z.string().min(1).optional(), +}); +export const uploadChecksumVerificationSchema = z.object({ + algorithm: z.literal("sha256"), + expected: z.string().min(1), + method: z.enum(["file_copy_sha256", "s3_checksum_sha256"]), + observed: z.string().min(1).optional(), + // `provider_declared` is the honest status for S3-compatible custom endpoints + // that may ignore the trailing ChecksumSHA256; only real AWS S3 validates it, + // which is `provider_validated`. `matched` is our own read-back byte compare. + status: z.enum(["matched", "provider_validated", "provider_declared"]), +}); +export const uploadQueueRunItemSchema = z.object({ + checksumVerification: uploadChecksumVerificationSchema.optional(), + itemId: z.string().min(1), + provider: uploadProviderSchema, + reason: z.string().min(1).optional(), + recordingId: z.string().min(1), + status: uploadQueueStatusSchema, +}); +export const uploadQueueRunSummarySchema = z.object({ + attempted: z.number().int().nonnegative(), + deferred: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + items: z.array(uploadQueueRunItemSchema), + succeeded: z.number().int().nonnegative(), +}); +export const uploadRunnerStatusSchema = z.object({ + batchSize: z.number().int().positive(), + intervalSeconds: z.number().int().positive(), + lastRunAt: isoDateTimeSchema.optional(), + lastSummary: uploadQueueRunSummarySchema.optional(), + running: z.boolean(), + started: z.boolean(), +}); + +export const uploadPolicyTriggerSchema = z.enum(["manual", "on_recording_cached"]); +export const uploadPolicySchema = z.object({ + deleteCacheAfterUpload: z.boolean().default(false), + destinationId: z.string().min(1).optional(), + enabled: z.boolean(), + id: z.string().min(1), + maxAttempts: z.number().int().positive().max(100), + name: z.string().min(1), + pathOverride: z.string().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema, + updatedAt: isoDateTimeSchema, +}); +export const uploadPolicyInputSchema = z.object({ + deleteCacheAfterUpload: z.boolean().default(false), + // Optional in the shared shape (the store is an internal seeding primitive), + // but the operator create route requires it — every policy must target a real + // destination or its recordings reconcile to `partial` (audit H3-3). + destinationId: z.string().trim().min(1).max(160).optional(), + enabled: z.boolean().default(true), + id: z.string().trim().min(1).max(160).optional(), + maxAttempts: z.number().int().positive().max(100).default(5), + name: z.string().trim().min(1).max(160), + pathOverride: z.string().trim().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema.default("manual"), +}); +export const uploadPolicyUpdateSchema = z + .object({ + deleteCacheAfterUpload: z.boolean().optional(), + destinationId: z.string().trim().min(1).max(160).optional(), + enabled: z.boolean().optional(), + maxAttempts: z.number().int().positive().max(100).optional(), + name: z.string().trim().min(1).max(160).optional(), + pathOverride: z.string().trim().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema.optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one upload policy field is required"); + +export const defaultStubUploadPolicy = { + deleteCacheAfterUpload: false, + enabled: true, + id: "upload-policy-stub", + maxAttempts: 5, + name: "Stub Upload Queue", + trigger: "manual", + updatedAt: "1970-01-01T00:00:00.000Z", +} satisfies UploadPolicy; + +export type UploadChecksumVerification = z.infer; +export type UploadPolicy = z.infer; +export type UploadPolicyInput = z.infer; +export type UploadPolicyTrigger = z.infer; +export type UploadPolicyUpdate = z.infer; +export type UploadQueueItem = z.infer; +export type UploadQueueRunItem = z.infer; +export type UploadQueueRunSummary = z.infer; +export type UploadRunnerStatus = z.infer; From 2f8520b58ad8977dd02abf3452d520366f5eb346 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 20:33:34 +0500 Subject: [PATCH 2/9] Split @rakkr/db schema into per-subsystem table modules Break the ~960-line schema.ts into cohesive modules under src/schema/ (enums, auth, access, rooms, nodes, node-credentials, audio, switchers, channel-maps, settings, schedules, recordings, uploads, room-roster, health, audit). Cross-table FK references form a clean import DAG; schema.ts re-exports every module so drizzle-kit (schema: ./src/schema.ts) and all consumers still see the full table set. drizzle-kit generate confirms no schema changes / no migration drift. Co-Authored-By: Claude Opus 4.8 --- packages/db/src/schema.ts | 979 +-------------------- packages/db/src/schema/access.ts | 72 ++ packages/db/src/schema/audio.ts | 61 ++ packages/db/src/schema/audit.ts | 48 + packages/db/src/schema/auth.ts | 115 +++ packages/db/src/schema/channel-maps.ts | 50 ++ packages/db/src/schema/enums.ts | 60 ++ packages/db/src/schema/health.ts | 34 + packages/db/src/schema/node-credentials.ts | 92 ++ packages/db/src/schema/nodes.ts | 43 + packages/db/src/schema/recordings.ts | 124 +++ packages/db/src/schema/room-roster.ts | 42 + packages/db/src/schema/rooms.ts | 22 + packages/db/src/schema/schedules.ts | 63 ++ packages/db/src/schema/settings.ts | 40 + packages/db/src/schema/switchers.ts | 83 ++ packages/db/src/schema/uploads.ts | 99 +++ 17 files changed, 1067 insertions(+), 960 deletions(-) create mode 100644 packages/db/src/schema/access.ts create mode 100644 packages/db/src/schema/audio.ts create mode 100644 packages/db/src/schema/audit.ts create mode 100644 packages/db/src/schema/auth.ts create mode 100644 packages/db/src/schema/channel-maps.ts create mode 100644 packages/db/src/schema/enums.ts create mode 100644 packages/db/src/schema/health.ts create mode 100644 packages/db/src/schema/node-credentials.ts create mode 100644 packages/db/src/schema/nodes.ts create mode 100644 packages/db/src/schema/recordings.ts create mode 100644 packages/db/src/schema/room-roster.ts create mode 100644 packages/db/src/schema/rooms.ts create mode 100644 packages/db/src/schema/schedules.ts create mode 100644 packages/db/src/schema/settings.ts create mode 100644 packages/db/src/schema/switchers.ts create mode 100644 packages/db/src/schema/uploads.ts diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6d4e84b2..6877adf2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,960 +1,19 @@ -import { sql } from "drizzle-orm"; -import { - bigint, - boolean, - index, - integer, - jsonb, - pgEnum, - pgTable, - primaryKey, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core"; - -export const nodeStatusEnum = pgEnum("node_status", [ - "provisioning", - "online", - "offline", - "degraded", - "recording", - "alerting", -]); - -export const healthSeverityEnum = pgEnum("health_severity", ["info", "warning", "critical"]); - -export const recordingStatusEnum = pgEnum("recording_status", [ - "queued", - "recording", - "completed", - "failed", - "cached", - "uploaded", - "partial", -]); -export const recordingJobStatusEnum = pgEnum("recording_job_status", [ - "queued", - "running", - "stop_requested", - "cancelled", - "completed", - "failed", -]); -export const recordingChunkStatusEnum = pgEnum("recording_chunk_status", [ - "capturing", - "cached", - "uploading", - "uploaded", - "partial", - "failed", -]); - -export const recordingSourceEnum = pgEnum("recording_source", ["ad_hoc", "schedule"]); - -export const auditOutcomeEnum = pgEnum("audit_outcome", [ - "allowed", - "denied", - "failed", - "partial", - "succeeded", -]); -export const accessPolicyEffectEnum = pgEnum("access_policy_effect", ["allow", "deny"]); -export const accessPolicySubjectTypeEnum = pgEnum("access_policy_subject_type", [ - "user", - "group", - "everyone", -]); -// Room roster subjects are always explicit (a user or a group); "everyone" is -// deliberately not a roster subject. -export const roomRosterSubjectTypeEnum = pgEnum("room_roster_subject_type", ["user", "group"]); -// Provenance of a roster entry: operator-managed, or materialized from a -// schedule/meeting assignment so calendar-derived grants reconcile independently. -export const roomRosterSourceEnum = pgEnum("room_roster_source", ["manual", "calendar"]); - -export const roles = pgTable("roles", { - description: text("description"), - id: varchar("id", { length: 64 }).primaryKey(), - name: varchar("name", { length: 120 }).notNull(), -}); - -export const permissions = pgTable("permissions", { - description: text("description"), - id: varchar("id", { length: 120 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), -}); - -export const rolePermissions = pgTable( - "role_permissions", - { - permissionId: varchar("permission_id", { length: 120 }) - .notNull() - .references(() => permissions.id, { onDelete: "cascade" }), - roleId: varchar("role_id", { length: 64 }) - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.roleId, table.permissionId] }), - }), -); - -export const users = pgTable( - "users", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - disabledAt: timestamp("disabled_at", { withTimezone: true }), - email: varchar("email", { length: 320 }).notNull().unique(), - // Stable federated-identity key (OIDC `oid`/`sub`). Null for local users. - // OIDC logins link on THIS, not email, so a mutable/unverified email claim - // can never merge onto another account (see the login-linking invariant). - externalId: varchar("external_id", { length: 320 }), - id: uuid("id").primaryKey().defaultRandom(), - name: varchar("name", { length: 160 }).notNull(), - passwordHash: text("password_hash"), - provider: varchar("provider", { length: 16 }).notNull().default("local"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - emailIdx: index("users_email_idx").on(table.email), - // Unique per federated subject; Postgres treats NULLs as distinct, so local - // users (external_id NULL) never collide. - externalIdIdx: uniqueIndex("users_external_id_idx").on(table.externalId), - }), -); - -export const userRoles = pgTable( - "user_roles", - { - roleId: varchar("role_id", { length: 64 }) - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.userId, table.roleId] }), - roleIdx: index("user_roles_role_idx").on(table.roleId), - }), -); - -export const accessGroups = pgTable("access_groups", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - description: text("description"), - id: varchar("id", { length: 120 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const userAccessGroups = pgTable( - "user_access_groups", - { - groupId: varchar("group_id", { length: 120 }) - .notNull() - .references(() => accessGroups.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - groupIdx: index("user_access_groups_group_idx").on(table.groupId), - pk: primaryKey({ columns: [table.userId, table.groupId] }), - }), -); - -export const accessPolicies = pgTable( - "access_policies", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - effect: accessPolicyEffectEnum("effect").notNull(), - id: uuid("id").primaryKey().defaultRandom(), - reason: text("reason"), - resourceId: varchar("resource_id", { length: 160 }).notNull(), - resourceType: varchar("resource_type", { length: 80 }).notNull(), - subjectId: varchar("subject_id", { length: 160 }), - subjectType: accessPolicySubjectTypeEnum("subject_type").notNull(), - }, - (table) => ({ - resourceIdx: index("access_policies_resource_idx").on(table.resourceType, table.resourceId), - subjectIdx: index("access_policies_subject_idx").on(table.subjectType, table.subjectId), - }), -); - -export const userResourceGrants = pgTable( - "user_resource_grants", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - resourceId: varchar("resource_id", { length: 160 }).notNull(), - resourceType: varchar("resource_type", { length: 80 }).notNull(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.userId, table.resourceType, table.resourceId] }), - resourceIdx: index("user_resource_grants_resource_idx").on( - table.resourceType, - table.resourceId, - ), - userIdx: index("user_resource_grants_user_idx").on(table.userId), - }), -); - -export const authSessions = pgTable( - "auth_sessions", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - ipAddress: varchar("ip_address", { length: 120 }), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - tokenHash: text("token_hash").notNull().unique(), - userAgent: text("user_agent"), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - expiresAtIdx: index("auth_sessions_expires_at_idx").on(table.expiresAt), - tokenHashIdx: index("auth_sessions_token_hash_idx").on(table.tokenHash), - userIdx: index("auth_sessions_user_idx").on(table.userId), - }), -); - -export const oidcLoginStates = pgTable( - "oidc_login_states", - { - codeVerifier: text("code_verifier").notNull(), - consumedAt: timestamp("consumed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - nonce: text("nonce").notNull(), - returnTo: text("return_to"), - stateHash: text("state_hash").primaryKey(), - }, - (table) => ({ - expiresAtIdx: index("oidc_login_states_expires_at_idx").on(table.expiresAt), - }), -); - -export const nodes = pgTable( - "nodes", - { - agentVersion: varchar("agent_version", { length: 80 }).notNull(), - alias: varchar("alias", { length: 160 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - hostname: varchar("hostname", { length: 255 }).notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), - location: jsonb("location") - .notNull() - .default(sql`'{}'::jsonb`), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - network: jsonb("network") - .notNull() - .default(sql`'{}'::jsonb`), - notes: text("notes"), - // First-class room this node belongs to (source of truth for room identity; - // location jsonb is retained for building/floor + legacy display). Operator- - // set via node management; the agent never sets it. - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - status: nodeStatusEnum("status").notNull().default("offline"), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - aliasIdx: index("nodes_alias_idx").on(table.alias), - roomIdx: index("nodes_room_idx").on(table.roomId), - statusIdx: index("nodes_status_idx").on(table.status), - }), -); - -// First-class room. `id` is the stable RBAC scope target (opaque); (site, name) -// is unique so backfill from node locations dedupes and renames don't collide. -export const rooms = pgTable( - "rooms", - { - building: varchar("building", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - description: text("description"), - floor: varchar("floor", { length: 160 }), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - notes: text("notes"), - site: varchar("site", { length: 160 }).notNull().default("Unknown Site"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - siteIdx: index("rooms_site_idx").on(table.site), - siteNameIdx: uniqueIndex("rooms_site_name_idx").on(table.site, table.name), - }), -); - -// Per-room access roster: one row per (room, subject, source[, sourceSchedule]). -// `capabilities` is the independently-toggled per-action set. Effective access -// for a user is the UNION of capabilities across their direct + group rows for a -// room. Uniqueness is enforced in the store (Postgres treats NULL -// sourceScheduleId as distinct, so a DB unique index would not dedupe manual rows). -export const roomRoster = pgTable( - "room_roster", - { - capabilities: jsonb("capabilities") - .notNull() - .default(sql`'[]'::jsonb`), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - roomId: varchar("room_id", { length: 160 }) - .notNull() - .references(() => rooms.id, { onDelete: "cascade" }), - source: roomRosterSourceEnum("source").notNull().default("manual"), - sourceScheduleId: varchar("source_schedule_id", { length: 160 }).references( - () => schedules.id, - { onDelete: "cascade" }, - ), - subjectId: varchar("subject_id", { length: 160 }).notNull(), - subjectType: roomRosterSubjectTypeEnum("subject_type").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - roomIdx: index("room_roster_room_idx").on(table.roomId), - scheduleIdx: index("room_roster_schedule_idx").on(table.sourceScheduleId), - subjectIdx: index("room_roster_subject_idx").on(table.subjectType, table.subjectId), - }), -); - -// External audio matrix switcher (e.g. AVPro AC-MAX). Non-secret connection -// config is columnar; the optional control-channel password (for models whose -// control channel requires a login) is encrypted at rest in `secrets` via -// secret-box. `model`, `inputs`, and `outputs` are fixed at creation. -// `mode`: disabled (never connect) | observe (compute + audit, never send) | -// enforce (apply routing). -export const switchers = pgTable("switchers", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - host: varchar("host", { length: 255 }).notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - inputs: integer("inputs").notNull(), - mode: varchar("mode", { length: 16 }).notNull().default("observe"), - model: varchar("model", { length: 48 }).notNull(), - outputs: integer("outputs").notNull(), - port: integer("port").notNull(), - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - username: varchar("username", { length: 120 }), -}); - -// Room feed wired to a switcher input jack. One room per input and one input -// per room (per switcher): the composite PK enforces one row per input, the -// room unique index enforces the reverse. -export const switcherInputMap = pgTable( - "switcher_input_map", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - input: integer("input").notNull(), - roomId: varchar("room_id", { length: 160 }) - .notNull() - .references(() => rooms.id, { onDelete: "cascade" }), - switcherId: varchar("switcher_id", { length: 160 }) - .notNull() - .references(() => switchers.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.switcherId, table.input] }), - roomIdx: index("switcher_input_map_room_idx").on(table.roomId), - roomUnique: uniqueIndex("switcher_input_map_room_unique").on(table.switcherId, table.roomId), - }), -); - -// Listener desk wired to a switcher output jack. One user per output and one -// output per user (per switcher). -export const switcherOutputMap = pgTable( - "switcher_output_map", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - output: integer("output").notNull(), - switcherId: varchar("switcher_id", { length: 160 }) - .notNull() - .references(() => switchers.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.switcherId, table.output] }), - userIdx: index("switcher_output_map_user_idx").on(table.userId), - userUnique: uniqueIndex("switcher_output_map_user_unique").on(table.switcherId, table.userId), - }), -); - -export const nodeCredentials = pgTable( - "node_credentials", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - tokenHash: text("token_hash").notNull().unique(), - tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), - }, - (table) => ({ - nodeIdx: index("node_credentials_node_idx").on(table.nodeId), - tokenPrefixIdx: index("node_credentials_token_prefix_idx").on(table.tokenPrefix), - // At most one un-revoked (active) credential per node — the DB-level guard for - // the invariant rotateCredential intends (revoke prior, then insert one). The - // revoke+insert is not atomic on its own, so two concurrent rotations could - // otherwise both insert an active credential (an un-revoked, still-valid stale - // token); this partial unique index makes the second insert fail closed. - activeNodeIdx: uniqueIndex("node_credentials_active_node_idx") - .on(table.nodeId) - .where(sql`revoked_at IS NULL`), - }), -); - -export const nodeSshCredentials = pgTable( - "node_ssh_credentials", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - fingerprint: varchar("fingerprint", { length: 160 }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - // SSH private keys must be replayable to authenticate the runner's SSH - // session, so they are encrypted at rest with the controller master key - // (AES-256-GCM, see node-ssh-credential-crypto), not hashed. - privateKeyEncrypted: text("private_key_encrypted").notNull(), - publicKey: text("public_key").notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - rotatedAt: timestamp("rotated_at", { withTimezone: true }), - username: varchar("username", { length: 64 }).notNull().default("rakkr"), - }, - (table) => ({ - nodeIdx: index("node_ssh_credentials_node_idx").on(table.nodeId), - // At most one un-revoked (active) credential per node — the DB-level guard for - // the "one active key per node" invariant. Revoke-then-insert in persistActive - // is not atomic on its own, so two concurrent rotations could otherwise both - // insert an active credential; this partial unique index makes the second - // insert fail (rolled back with its transaction) instead. - activeNodeIdx: uniqueIndex("node_ssh_credentials_active_node_idx") - .on(table.nodeId) - .where(sql`revoked_at IS NULL`), - }), -); - -export const nodeBootstrapTokens = pgTable( - "node_bootstrap_tokens", - { - consumedAt: timestamp("consumed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - // Single-use, short-TTL bearer presented once at first boot to hand the - // node-generated SSH key to the controller. Hashed at rest like node tokens. - tokenHash: text("token_hash").notNull().unique(), - tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), - }, - (table) => ({ - nodeIdx: index("node_bootstrap_tokens_node_idx").on(table.nodeId), - }), -); - -export const audioInterfaces = pgTable( - "audio_interfaces", - { - // Set when the agent's startup inventory reconcile no longer reports this - // interface. Absent interfaces are flagged (preserving channel-map history) - // rather than hard-deleted; cleared when the device is reported again. - absentAt: timestamp("absent_at", { withTimezone: true }), - alias: varchar("alias", { length: 160 }).notNull(), - backend: varchar("backend", { length: 40 }).notNull(), - channelCount: integer("channel_count").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - hardwarePath: varchar("hardware_path", { length: 500 }), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - sampleRates: jsonb("sample_rates") - .notNull() - .default(sql`'[]'::jsonb`), - serialNumber: varchar("serial_number", { length: 255 }), - systemName: varchar("system_name", { length: 255 }).notNull(), - systemRef: varchar("system_ref", { length: 255 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - nodeIdx: index("audio_interfaces_node_idx").on(table.nodeId), - }), -); - -export const audioChannels = pgTable( - "audio_channels", - { - alias: varchar("alias", { length: 160 }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - index: integer("channel_index").notNull(), - interfaceId: uuid("interface_id") - .notNull() - .references(() => audioInterfaces.id, { onDelete: "cascade" }), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - // Room that owns this channel. Room ownership lives at the channel level: any - // set of a node's channels can belong to a room, and each channel belongs to - // at most one room. NULL inherits the node default (nodes.room_id). Set null on - // room delete so hardware rows survive. Preserved across inventory reconcile - // (matched on interface + channel_index). - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - }, - (table) => ({ - interfaceIdx: index("audio_channels_interface_idx").on(table.interfaceId), - roomIdx: index("audio_channels_room_idx").on(table.roomId), - }), -); - -export const recordingProfiles = pgTable("recording_profiles", { - bitrateKbps: integer("bitrate_kbps").notNull(), - channelMode: varchar("channel_mode", { length: 64 }).notNull(), - codec: varchar("codec", { length: 32 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - settings: jsonb("settings") - .notNull() - .default(sql`'{}'::jsonb`), - silenceDetectionEnabled: boolean("silence_detection_enabled").notNull().default(false), - silenceSkipEnabled: boolean("silence_skip_enabled").notNull().default(false), - vbr: boolean("vbr").notNull().default(true), -}); - -export const controllerSettings = pgTable("controller_settings", { - controllerName: varchar("controller_name", { length: 160 }).notNull().default("Rakkr Controller"), - // Operator-chosen scheduling/ad-hoc defaults; null = no default set (forms - // fall back to the built-in profile/policy). Nullable, no FK: a referenced - // policy may be deleted, and the forms tolerate a stale id. - defaultRecordingProfileId: varchar("default_recording_profile_id", { length: 160 }), - defaultRetentionPolicyId: varchar("default_retention_policy_id", { length: 160 }), - defaultUploadPolicyId: varchar("default_upload_policy_id", { length: 160 }), - defaultWatchdogPolicyId: varchar("default_watchdog_policy_id", { length: 160 }), - id: varchar("id", { length: 64 }).primaryKey().default("controller"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - weekStartsOn: varchar("week_starts_on", { length: 16 }).notNull().default("monday"), -}); - -export const watchdogPolicies = pgTable("watchdog_policies", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - rules: jsonb("rules") - .notNull() - .default(sql`'[]'::jsonb`), -}); - -export const channelMapTemplates = pgTable("channel_map_templates", { - channelMode: varchar("channel_mode", { length: 64 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - entries: jsonb("entries") - .notNull() - .default(sql`'[]'::jsonb`), - id: varchar("id", { length: 160 }).primaryKey(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - name: varchar("name", { length: 160 }).notNull(), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const templateAssignments = pgTable( - "template_assignments", - { - assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(), - assignedByUserId: uuid("assigned_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - targetId: varchar("target_id", { length: 160 }).notNull(), - targetType: varchar("target_type", { length: 40 }).notNull(), - templateId: varchar("template_id", { length: 160 }).notNull(), - templateKind: varchar("template_kind", { length: 80 }).notNull(), - }, - (table) => ({ - targetIdx: index("template_assignments_target_idx").on( - table.templateKind, - table.targetType, - table.targetId, - ), - templateIdx: index("template_assignments_template_idx").on( - table.templateKind, - table.templateId, - ), - }), -); - -export const uploadProviders = pgTable("upload_providers", { - // Non-secret typed connection config (smb/s3 fields). - config: jsonb("config") - .notNull() - .default(sql`'{}'::jsonb`), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - provider: varchar("provider", { length: 32 }).primaryKey(), - // Encrypted secret material (smbPassword, s3SecretAccessKey). - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -// Named SMB/S3 upload targets. Unlike upload_providers (one row per kind), many -// destinations of each kind may exist; upload policies reference one by id. -export const uploadDestinations = pgTable("upload_destinations", { - // Non-secret typed connection config (smb/s3 fields). - config: jsonb("config") - .notNull() - .default(sql`'{}'::jsonb`), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - id: varchar("id", { length: 160 }).primaryKey(), - kind: varchar("kind", { length: 32 }).notNull(), - // Encrypted secret material (smbPassword, s3SecretAccessKey). - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const uploadPolicies = pgTable("upload_policies", { - deleteCacheAfterUpload: boolean("delete_cache_after_upload").notNull().default(false), - destinationId: varchar("destination_id", { length: 160 }), - enabled: boolean("enabled").notNull().default(false), - id: varchar("id", { length: 160 }).primaryKey(), - maxAttempts: integer("max_attempts").notNull().default(5), - name: varchar("name", { length: 160 }).notNull(), - pathOverride: text("path_override"), - // Legacy columns superseded by destinationId/pathOverride; retained nullable for - // backfill and dropped in a later migration. - provider: varchar("provider", { length: 32 }), - target: text("target"), - trigger: varchar("trigger", { length: 40 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const uploadQueueItems = pgTable( - "upload_queue_items", - { - attemptCount: integer("attempt_count").notNull().default(0), - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - // Chunk this upload item belongs to. NULL = legacy whole-recording item. - chunkId: varchar("chunk_id", { length: 160 }), - chunkIndex: integer("chunk_index"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - destinationId: varchar("destination_id", { length: 160 }), - fileName: text("file_name").notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - lastError: text("last_error"), - maxAttempts: integer("max_attempts").notNull().default(5), - nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(), - pathOverride: text("path_override"), - provider: varchar("provider", { length: 32 }).notNull(), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - status: varchar("status", { length: 32 }).notNull(), - target: text("target"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - uploadPolicyId: varchar("upload_policy_id", { length: 160 }), - }, - (table) => ({ - chunkIdx: index("upload_queue_items_chunk_idx").on(table.chunkId), - destinationStatusIdx: index("upload_queue_items_destination_status_idx").on( - table.destinationId, - table.status, - ), - dueIdx: index("upload_queue_items_due_idx").on(table.status, table.nextAttemptAt), - providerStatusIdx: index("upload_queue_items_provider_status_idx").on( - table.provider, - table.status, - ), - recordingIdx: index("upload_queue_items_recording_idx").on(table.recordingId), - }), -); - -export const schedules = pgTable( - "schedules", - { - // Access-group ids assigned to this schedule. Membership in an assigned - // group confers scoped RBAC (see the assignment capability bundle in the - // API) over the schedule's room. Groups are evaluated dynamically. - assignedGroupIds: jsonb("assigned_group_ids") - .notNull() - .default(sql`'[]'::jsonb`), - // User ids directly assigned to this schedule. Assignment confers scoped - // RBAC over the schedule's room without changing the user's role. - assignedUserIds: jsonb("assigned_user_ids") - .notNull() - .default(sql`'[]'::jsonb`), - captureBackend: varchar("capture_backend", { length: 32 }), - // Ordered 1-based source channel indices selected from the capture - // interface. Empty array = capture the whole interface (legacy behavior). - captureChannelSelection: jsonb("capture_channel_selection") - .notNull() - .default(sql`'[]'::jsonb`), - captureInterfaceId: varchar("capture_interface_id", { length: 160 }), - // Output mode applied to the selected channels (mono/stereo/etc). - channelMode: varchar("channel_mode", { length: 32 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - enabled: boolean("enabled").notNull().default(true), - folderTemplate: text("folder_template").notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - nextRunAt: timestamp("next_run_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }), - recurrence: jsonb("recurrence").notNull(), - recordingProfileId: varchar("recording_profile_id", { length: 160 }), - retentionPolicyId: varchar("retention_policy_id", { length: 160 }), - // Denormalized room display name/template value; retained for one release. - // roomId is the source of truth for room identity and RBAC scope. - room: varchar("room", { length: 160 }).notNull().default("Unknown Room"), - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "restrict", - }), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - timezone: varchar("timezone", { length: 80 }).notNull(), - titleTemplate: text("title_template").notNull(), - // Legacy single-policy column superseded by uploadPolicyIds; retained nullable - // for backfill and dropped in a later migration. - uploadPolicyId: varchar("upload_policy_id", { length: 160 }), - uploadPolicyIds: jsonb("upload_policy_ids") - .notNull() - .default(sql`'[]'::jsonb`), - watchdogPolicyId: varchar("watchdog_policy_id", { length: 160 }), - }, - (table) => ({ - enabledIdx: index("schedules_enabled_idx").on(table.enabled), - nodeIdx: index("schedules_node_idx").on(table.nodeId), - roomIdx: index("schedules_room_idx").on(table.roomId), - }), -); - -export const recordings = pgTable( - "recordings", - { - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - durationSeconds: integer("duration_seconds").notNull().default(0), - // Voice-enhancement renditions. `cachePath` stays the default-playback file; - // `rawCachePath`/`enhancedCachePath` are set when both renditions exist so the - // player and live monitor can switch between raw and enhanced audio. - enhancedCachePath: text("enhanced_cache_path"), - folder: text("folder").notNull(), - rawCachePath: text("raw_cache_path"), - healthStatus: varchar("health_status", { length: 32 }).notNull().default("unknown"), - id: varchar("id", { length: 160 }).primaryKey(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - name: text("name").notNull(), - nodeId: varchar("node_id", { length: 160 }), - recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), - // Room that owns this recording, captured at create time from the room of the - // selected channels. Persisted (not derived) so a later channel reassignment - // never retroactively moves a completed recording or its RBAC visibility. - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - scheduleId: varchar("schedule_id", { length: 160 }), - source: recordingSourceEnum("source").notNull(), - status: recordingStatusEnum("status").notNull(), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - }, - (table) => ({ - recordedAtIdx: index("recordings_recorded_at_idx").on(table.recordedAt), - roomIdx: index("recordings_room_idx").on(table.roomId), - scheduleIdx: index("recordings_schedule_idx").on(table.scheduleId), - statusIdx: index("recordings_status_idx").on(table.status), - }), -); - -export const recordingJobs = pgTable( - "recording_jobs", - { - claimedBy: varchar("claimed_by", { length: 160 }), - command: jsonb("command").notNull(), - completedAt: timestamp("completed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - failureReason: text("failure_reason"), - id: varchar("id", { length: 120 }).primaryKey(), - lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), - leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }).notNull(), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - startedAt: timestamp("started_at", { withTimezone: true }), - status: recordingJobStatusEnum("status").notNull(), - stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - leaseIdx: index("recording_jobs_lease_idx").on(table.status, table.leaseExpiresAt), - nodeStatusIdx: index("recording_jobs_node_status_idx").on(table.nodeId, table.status), - recordingIdx: index("recording_jobs_recording_idx").on(table.recordingId), - }), -); - -// One time-based segment of a recording. Many chunks belong to one recording + -// one job (`recordingId`/`jobId`); `index` is 1-based and `total` is filled in -// only once capture stops. Each chunk renders raw + enhanced and uploads as it -// closes, then fans out to its own per-destination upload_queue_items. -export const recordingChunks = pgTable( - "recording_chunks", - { - cachedAt: timestamp("cached_at", { withTimezone: true }), - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - durationSeconds: integer("duration_seconds").notNull().default(0), - enhancedCachePath: text("enhanced_cache_path"), - id: varchar("id", { length: 160 }).primaryKey(), - index: integer("index").notNull(), - jobId: varchar("job_id", { length: 160 }).notNull(), - offsetSeconds: integer("offset_seconds").notNull().default(0), - rawCachePath: text("raw_cache_path"), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - // A single chunk of multichannel/uncompressed audio (e.g. 32-ch X32 WAV) can - // exceed the 2 GiB signed-int32 ceiling, which threw "integer out of range" on - // upsert and failed the chunk. bigint (JS number, exact to 2^53 = 8 PiB) holds it. - sizeBytes: bigint("size_bytes", { mode: "number" }), - status: recordingChunkStatusEnum("status").notNull(), - total: integer("total"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - jobIdx: index("recording_chunks_job_idx").on(table.jobId), - recordingIdx: index("recording_chunks_recording_idx").on(table.recordingId), - recordingIndexUnique: uniqueIndex("recording_chunks_recording_index_unique").on( - table.recordingId, - table.index, - ), - }), -); - -export const healthEvents = pgTable( - "health_events", - { - acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }), - acknowledgedBy: varchar("acknowledged_by", { length: 160 }), - details: jsonb("details") - .notNull() - .default(sql`'{}'::jsonb`), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }), - openedAt: timestamp("opened_at", { withTimezone: true }).notNull(), - recordingId: varchar("recording_id", { length: 160 }), - resolvedAt: timestamp("resolved_at", { withTimezone: true }), - resolvedBy: varchar("resolved_by", { length: 160 }), - scheduleId: varchar("schedule_id", { length: 160 }), - severity: healthSeverityEnum("severity").notNull(), - status: varchar("status", { length: 32 }).notNull().default("open"), - suppressedAt: timestamp("suppressed_at", { withTimezone: true }), - suppressedBy: varchar("suppressed_by", { length: 160 }), - suppressedUntil: timestamp("suppressed_until", { withTimezone: true }), - type: varchar("type", { length: 160 }).notNull(), - }, - (table) => ({ - nodeIdx: index("health_events_node_idx").on(table.nodeId), - openedAtIdx: index("health_events_opened_at_idx").on(table.openedAt), - severityIdx: index("health_events_severity_idx").on(table.severity), - statusIdx: index("health_events_status_idx").on(table.status), - }), -); - -export const auditEvents = pgTable( - "audit_events", - { - action: varchar("action", { length: 160 }).notNull(), - actorContext: jsonb("actor_context") - .notNull() - .default(sql`'{}'::jsonb`), - actorDisplayName: varchar("actor_display_name", { length: 160 }), - actorId: varchar("actor_id", { length: 160 }), - actorRoles: jsonb("actor_roles") - .notNull() - .default(sql`'[]'::jsonb`), - actorType: varchar("actor_type", { length: 40 }).notNull().default("user"), - actorUserId: uuid("actor_user_id").references(() => users.id, { - onDelete: "set null", - }), - after: jsonb("after"), - before: jsonb("before"), - correlationIds: jsonb("correlation_ids") - .notNull() - .default(sql`'{}'::jsonb`), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - details: jsonb("details") - .notNull() - .default(sql`'{}'::jsonb`), - id: uuid("id").primaryKey().defaultRandom(), - outcome: auditOutcomeEnum("outcome").notNull(), - permissionId: varchar("permission_id", { length: 120 }), - reason: text("reason"), - targetId: varchar("target_id", { length: 160 }), - targetName: varchar("target_name", { length: 160 }), - targetType: varchar("target_type", { length: 160 }), - }, - (table) => ({ - actionIdx: index("audit_events_action_idx").on(table.action), - actorIdx: index("audit_events_actor_idx").on(table.actorId), - createdAtIdx: index("audit_events_created_at_idx").on(table.createdAt), - outcomeIdx: index("audit_events_outcome_idx").on(table.outcome), - permissionIdx: index("audit_events_permission_idx").on(table.permissionId), - targetIdx: index("audit_events_target_idx").on(table.targetType, table.targetId), - }), -); +// Drizzle schema surface. Tables live in per-subsystem modules under ./schema; +// this file re-exports them so drizzle-kit (configured with schema: +// ./src/schema.ts) and every consumer see the full table set from one entry. +export * from "./schema/enums.js"; +export * from "./schema/auth.js"; +export * from "./schema/access.js"; +export * from "./schema/rooms.js"; +export * from "./schema/nodes.js"; +export * from "./schema/node-credentials.js"; +export * from "./schema/audio.js"; +export * from "./schema/switchers.js"; +export * from "./schema/channel-maps.js"; +export * from "./schema/settings.js"; +export * from "./schema/schedules.js"; +export * from "./schema/recordings.js"; +export * from "./schema/uploads.js"; +export * from "./schema/room-roster.js"; +export * from "./schema/health.js"; +export * from "./schema/audit.js"; diff --git a/packages/db/src/schema/access.ts b/packages/db/src/schema/access.ts new file mode 100644 index 00000000..f9ff6f68 --- /dev/null +++ b/packages/db/src/schema/access.ts @@ -0,0 +1,72 @@ +import { index, pgTable, primaryKey, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { accessPolicyEffectEnum, accessPolicySubjectTypeEnum } from "./enums.js"; +import { users } from "./auth.js"; + +export const accessGroups = pgTable("access_groups", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + description: text("description"), + id: varchar("id", { length: 120 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const userAccessGroups = pgTable( + "user_access_groups", + { + groupId: varchar("group_id", { length: 120 }) + .notNull() + .references(() => accessGroups.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + groupIdx: index("user_access_groups_group_idx").on(table.groupId), + pk: primaryKey({ columns: [table.userId, table.groupId] }), + }), +); + +export const accessPolicies = pgTable( + "access_policies", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + effect: accessPolicyEffectEnum("effect").notNull(), + id: uuid("id").primaryKey().defaultRandom(), + reason: text("reason"), + resourceId: varchar("resource_id", { length: 160 }).notNull(), + resourceType: varchar("resource_type", { length: 80 }).notNull(), + subjectId: varchar("subject_id", { length: 160 }), + subjectType: accessPolicySubjectTypeEnum("subject_type").notNull(), + }, + (table) => ({ + resourceIdx: index("access_policies_resource_idx").on(table.resourceType, table.resourceId), + subjectIdx: index("access_policies_subject_idx").on(table.subjectType, table.subjectId), + }), +); + +export const userResourceGrants = pgTable( + "user_resource_grants", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + resourceId: varchar("resource_id", { length: 160 }).notNull(), + resourceType: varchar("resource_type", { length: 80 }).notNull(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.resourceType, table.resourceId] }), + resourceIdx: index("user_resource_grants_resource_idx").on( + table.resourceType, + table.resourceId, + ), + userIdx: index("user_resource_grants_user_idx").on(table.userId), + }), +); diff --git a/packages/db/src/schema/audio.ts b/packages/db/src/schema/audio.ts new file mode 100644 index 00000000..00ae7dac --- /dev/null +++ b/packages/db/src/schema/audio.ts @@ -0,0 +1,61 @@ +import { sql } from "drizzle-orm"; +import { index, integer, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { nodes } from "./nodes.js"; +import { rooms } from "./rooms.js"; + +export const audioInterfaces = pgTable( + "audio_interfaces", + { + // Set when the agent's startup inventory reconcile no longer reports this + // interface. Absent interfaces are flagged (preserving channel-map history) + // rather than hard-deleted; cleared when the device is reported again. + absentAt: timestamp("absent_at", { withTimezone: true }), + alias: varchar("alias", { length: 160 }).notNull(), + backend: varchar("backend", { length: 40 }).notNull(), + channelCount: integer("channel_count").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + hardwarePath: varchar("hardware_path", { length: 500 }), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + sampleRates: jsonb("sample_rates") + .notNull() + .default(sql`'[]'::jsonb`), + serialNumber: varchar("serial_number", { length: 255 }), + systemName: varchar("system_name", { length: 255 }).notNull(), + systemRef: varchar("system_ref", { length: 255 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + nodeIdx: index("audio_interfaces_node_idx").on(table.nodeId), + }), +); + +export const audioChannels = pgTable( + "audio_channels", + { + alias: varchar("alias", { length: 160 }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + index: integer("channel_index").notNull(), + interfaceId: uuid("interface_id") + .notNull() + .references(() => audioInterfaces.id, { onDelete: "cascade" }), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + // Room that owns this channel. Room ownership lives at the channel level: any + // set of a node's channels can belong to a room, and each channel belongs to + // at most one room. NULL inherits the node default (nodes.room_id). Set null on + // room delete so hardware rows survive. Preserved across inventory reconcile + // (matched on interface + channel_index). + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + }, + (table) => ({ + interfaceIdx: index("audio_channels_interface_idx").on(table.interfaceId), + roomIdx: index("audio_channels_room_idx").on(table.roomId), + }), +); diff --git a/packages/db/src/schema/audit.ts b/packages/db/src/schema/audit.ts new file mode 100644 index 00000000..cb12187d --- /dev/null +++ b/packages/db/src/schema/audit.ts @@ -0,0 +1,48 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { auditOutcomeEnum } from "./enums.js"; + +export const auditEvents = pgTable( + "audit_events", + { + action: varchar("action", { length: 160 }).notNull(), + actorContext: jsonb("actor_context") + .notNull() + .default(sql`'{}'::jsonb`), + actorDisplayName: varchar("actor_display_name", { length: 160 }), + actorId: varchar("actor_id", { length: 160 }), + actorRoles: jsonb("actor_roles") + .notNull() + .default(sql`'[]'::jsonb`), + actorType: varchar("actor_type", { length: 40 }).notNull().default("user"), + actorUserId: uuid("actor_user_id").references(() => users.id, { + onDelete: "set null", + }), + after: jsonb("after"), + before: jsonb("before"), + correlationIds: jsonb("correlation_ids") + .notNull() + .default(sql`'{}'::jsonb`), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + details: jsonb("details") + .notNull() + .default(sql`'{}'::jsonb`), + id: uuid("id").primaryKey().defaultRandom(), + outcome: auditOutcomeEnum("outcome").notNull(), + permissionId: varchar("permission_id", { length: 120 }), + reason: text("reason"), + targetId: varchar("target_id", { length: 160 }), + targetName: varchar("target_name", { length: 160 }), + targetType: varchar("target_type", { length: 160 }), + }, + (table) => ({ + actionIdx: index("audit_events_action_idx").on(table.action), + actorIdx: index("audit_events_actor_idx").on(table.actorId), + createdAtIdx: index("audit_events_created_at_idx").on(table.createdAt), + outcomeIdx: index("audit_events_outcome_idx").on(table.outcome), + permissionIdx: index("audit_events_permission_idx").on(table.permissionId), + targetIdx: index("audit_events_target_idx").on(table.targetType, table.targetId), + }), +); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 00000000..dd4c818d --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,115 @@ +import { + index, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +export const roles = pgTable("roles", { + description: text("description"), + id: varchar("id", { length: 64 }).primaryKey(), + name: varchar("name", { length: 120 }).notNull(), +}); + +export const permissions = pgTable("permissions", { + description: text("description"), + id: varchar("id", { length: 120 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), +}); + +export const rolePermissions = pgTable( + "role_permissions", + { + permissionId: varchar("permission_id", { length: 120 }) + .notNull() + .references(() => permissions.id, { onDelete: "cascade" }), + roleId: varchar("role_id", { length: 64 }) + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roleId, table.permissionId] }), + }), +); + +export const users = pgTable( + "users", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + disabledAt: timestamp("disabled_at", { withTimezone: true }), + email: varchar("email", { length: 320 }).notNull().unique(), + // Stable federated-identity key (OIDC `oid`/`sub`). Null for local users. + // OIDC logins link on THIS, not email, so a mutable/unverified email claim + // can never merge onto another account (see the login-linking invariant). + externalId: varchar("external_id", { length: 320 }), + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 160 }).notNull(), + passwordHash: text("password_hash"), + provider: varchar("provider", { length: 16 }).notNull().default("local"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + emailIdx: index("users_email_idx").on(table.email), + // Unique per federated subject; Postgres treats NULLs as distinct, so local + // users (external_id NULL) never collide. + externalIdIdx: uniqueIndex("users_external_id_idx").on(table.externalId), + }), +); + +export const userRoles = pgTable( + "user_roles", + { + roleId: varchar("role_id", { length: 64 }) + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.roleId] }), + roleIdx: index("user_roles_role_idx").on(table.roleId), + }), +); + +export const authSessions = pgTable( + "auth_sessions", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + ipAddress: varchar("ip_address", { length: 120 }), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + tokenHash: text("token_hash").notNull().unique(), + userAgent: text("user_agent"), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + expiresAtIdx: index("auth_sessions_expires_at_idx").on(table.expiresAt), + tokenHashIdx: index("auth_sessions_token_hash_idx").on(table.tokenHash), + userIdx: index("auth_sessions_user_idx").on(table.userId), + }), +); + +export const oidcLoginStates = pgTable( + "oidc_login_states", + { + codeVerifier: text("code_verifier").notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + nonce: text("nonce").notNull(), + returnTo: text("return_to"), + stateHash: text("state_hash").primaryKey(), + }, + (table) => ({ + expiresAtIdx: index("oidc_login_states_expires_at_idx").on(table.expiresAt), + }), +); diff --git a/packages/db/src/schema/channel-maps.ts b/packages/db/src/schema/channel-maps.ts new file mode 100644 index 00000000..e1b3b7f4 --- /dev/null +++ b/packages/db/src/schema/channel-maps.ts @@ -0,0 +1,50 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; + +export const channelMapTemplates = pgTable("channel_map_templates", { + channelMode: varchar("channel_mode", { length: 64 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + entries: jsonb("entries") + .notNull() + .default(sql`'[]'::jsonb`), + id: varchar("id", { length: 160 }).primaryKey(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + name: varchar("name", { length: 160 }).notNull(), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const templateAssignments = pgTable( + "template_assignments", + { + assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(), + assignedByUserId: uuid("assigned_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + targetId: varchar("target_id", { length: 160 }).notNull(), + targetType: varchar("target_type", { length: 40 }).notNull(), + templateId: varchar("template_id", { length: 160 }).notNull(), + templateKind: varchar("template_kind", { length: 80 }).notNull(), + }, + (table) => ({ + targetIdx: index("template_assignments_target_idx").on( + table.templateKind, + table.targetType, + table.targetId, + ), + templateIdx: index("template_assignments_template_idx").on( + table.templateKind, + table.templateId, + ), + }), +); diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts new file mode 100644 index 00000000..54397497 --- /dev/null +++ b/packages/db/src/schema/enums.ts @@ -0,0 +1,60 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +export const nodeStatusEnum = pgEnum("node_status", [ + "provisioning", + "online", + "offline", + "degraded", + "recording", + "alerting", +]); + +export const healthSeverityEnum = pgEnum("health_severity", ["info", "warning", "critical"]); + +export const recordingStatusEnum = pgEnum("recording_status", [ + "queued", + "recording", + "completed", + "failed", + "cached", + "uploaded", + "partial", +]); +export const recordingJobStatusEnum = pgEnum("recording_job_status", [ + "queued", + "running", + "stop_requested", + "cancelled", + "completed", + "failed", +]); +export const recordingChunkStatusEnum = pgEnum("recording_chunk_status", [ + "capturing", + "cached", + "uploading", + "uploaded", + "partial", + "failed", +]); + +export const recordingSourceEnum = pgEnum("recording_source", ["ad_hoc", "schedule"]); + +export const auditOutcomeEnum = pgEnum("audit_outcome", [ + "allowed", + "denied", + "failed", + "partial", + "succeeded", +]); +export const accessPolicyEffectEnum = pgEnum("access_policy_effect", ["allow", "deny"]); +export const accessPolicySubjectTypeEnum = pgEnum("access_policy_subject_type", [ + "user", + "group", + "everyone", +]); +// Room roster subjects are always explicit (a user or a group); "everyone" is +// deliberately not a roster subject. +export const roomRosterSubjectTypeEnum = pgEnum("room_roster_subject_type", ["user", "group"]); +// Provenance of a roster entry: operator-managed, or materialized from a +// schedule/meeting assignment so calendar-derived grants reconcile independently. +export const roomRosterSourceEnum = pgEnum("room_roster_source", ["manual", "calendar"]); diff --git a/packages/db/src/schema/health.ts b/packages/db/src/schema/health.ts new file mode 100644 index 00000000..86342f09 --- /dev/null +++ b/packages/db/src/schema/health.ts @@ -0,0 +1,34 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { healthSeverityEnum } from "./enums.js"; + +export const healthEvents = pgTable( + "health_events", + { + acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }), + acknowledgedBy: varchar("acknowledged_by", { length: 160 }), + details: jsonb("details") + .notNull() + .default(sql`'{}'::jsonb`), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }), + openedAt: timestamp("opened_at", { withTimezone: true }).notNull(), + recordingId: varchar("recording_id", { length: 160 }), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + resolvedBy: varchar("resolved_by", { length: 160 }), + scheduleId: varchar("schedule_id", { length: 160 }), + severity: healthSeverityEnum("severity").notNull(), + status: varchar("status", { length: 32 }).notNull().default("open"), + suppressedAt: timestamp("suppressed_at", { withTimezone: true }), + suppressedBy: varchar("suppressed_by", { length: 160 }), + suppressedUntil: timestamp("suppressed_until", { withTimezone: true }), + type: varchar("type", { length: 160 }).notNull(), + }, + (table) => ({ + nodeIdx: index("health_events_node_idx").on(table.nodeId), + openedAtIdx: index("health_events_opened_at_idx").on(table.openedAt), + severityIdx: index("health_events_severity_idx").on(table.severity), + statusIdx: index("health_events_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/node-credentials.ts b/packages/db/src/schema/node-credentials.ts new file mode 100644 index 00000000..66ef6bcd --- /dev/null +++ b/packages/db/src/schema/node-credentials.ts @@ -0,0 +1,92 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { nodes } from "./nodes.js"; + +export const nodeCredentials = pgTable( + "node_credentials", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + tokenHash: text("token_hash").notNull().unique(), + tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), + }, + (table) => ({ + nodeIdx: index("node_credentials_node_idx").on(table.nodeId), + tokenPrefixIdx: index("node_credentials_token_prefix_idx").on(table.tokenPrefix), + // At most one un-revoked (active) credential per node — the DB-level guard for + // the invariant rotateCredential intends (revoke prior, then insert one). The + // revoke+insert is not atomic on its own, so two concurrent rotations could + // otherwise both insert an active credential (an un-revoked, still-valid stale + // token); this partial unique index makes the second insert fail closed. + activeNodeIdx: uniqueIndex("node_credentials_active_node_idx") + .on(table.nodeId) + .where(sql`revoked_at IS NULL`), + }), +); + +export const nodeSshCredentials = pgTable( + "node_ssh_credentials", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + fingerprint: varchar("fingerprint", { length: 160 }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + // SSH private keys must be replayable to authenticate the runner's SSH + // session, so they are encrypted at rest with the controller master key + // (AES-256-GCM, see node-ssh-credential-crypto), not hashed. + privateKeyEncrypted: text("private_key_encrypted").notNull(), + publicKey: text("public_key").notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + rotatedAt: timestamp("rotated_at", { withTimezone: true }), + username: varchar("username", { length: 64 }).notNull().default("rakkr"), + }, + (table) => ({ + nodeIdx: index("node_ssh_credentials_node_idx").on(table.nodeId), + // At most one un-revoked (active) credential per node — the DB-level guard for + // the "one active key per node" invariant. Revoke-then-insert in persistActive + // is not atomic on its own, so two concurrent rotations could otherwise both + // insert an active credential; this partial unique index makes the second + // insert fail (rolled back with its transaction) instead. + activeNodeIdx: uniqueIndex("node_ssh_credentials_active_node_idx") + .on(table.nodeId) + .where(sql`revoked_at IS NULL`), + }), +); + +export const nodeBootstrapTokens = pgTable( + "node_bootstrap_tokens", + { + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + // Single-use, short-TTL bearer presented once at first boot to hand the + // node-generated SSH key to the controller. Hashed at rest like node tokens. + tokenHash: text("token_hash").notNull().unique(), + tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), + }, + (table) => ({ + nodeIdx: index("node_bootstrap_tokens_node_idx").on(table.nodeId), + }), +); diff --git a/packages/db/src/schema/nodes.ts b/packages/db/src/schema/nodes.ts new file mode 100644 index 00000000..3fec2884 --- /dev/null +++ b/packages/db/src/schema/nodes.ts @@ -0,0 +1,43 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; + +import { nodeStatusEnum } from "./enums.js"; +import { rooms } from "./rooms.js"; + +export const nodes = pgTable( + "nodes", + { + agentVersion: varchar("agent_version", { length: 80 }).notNull(), + alias: varchar("alias", { length: 160 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + hostname: varchar("hostname", { length: 255 }).notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + location: jsonb("location") + .notNull() + .default(sql`'{}'::jsonb`), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + network: jsonb("network") + .notNull() + .default(sql`'{}'::jsonb`), + notes: text("notes"), + // First-class room this node belongs to (source of truth for room identity; + // location jsonb is retained for building/floor + legacy display). Operator- + // set via node management; the agent never sets it. + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + status: nodeStatusEnum("status").notNull().default("offline"), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + aliasIdx: index("nodes_alias_idx").on(table.alias), + roomIdx: index("nodes_room_idx").on(table.roomId), + statusIdx: index("nodes_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/recordings.ts b/packages/db/src/schema/recordings.ts new file mode 100644 index 00000000..7c02c1fa --- /dev/null +++ b/packages/db/src/schema/recordings.ts @@ -0,0 +1,124 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; + +import { + recordingChunkStatusEnum, + recordingJobStatusEnum, + recordingSourceEnum, + recordingStatusEnum, +} from "./enums.js"; +import { rooms } from "./rooms.js"; + +export const recordings = pgTable( + "recordings", + { + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + durationSeconds: integer("duration_seconds").notNull().default(0), + // Voice-enhancement renditions. `cachePath` stays the default-playback file; + // `rawCachePath`/`enhancedCachePath` are set when both renditions exist so the + // player and live monitor can switch between raw and enhanced audio. + enhancedCachePath: text("enhanced_cache_path"), + folder: text("folder").notNull(), + rawCachePath: text("raw_cache_path"), + healthStatus: varchar("health_status", { length: 32 }).notNull().default("unknown"), + id: varchar("id", { length: 160 }).primaryKey(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + name: text("name").notNull(), + nodeId: varchar("node_id", { length: 160 }), + recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), + // Room that owns this recording, captured at create time from the room of the + // selected channels. Persisted (not derived) so a later channel reassignment + // never retroactively moves a completed recording or its RBAC visibility. + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + scheduleId: varchar("schedule_id", { length: 160 }), + source: recordingSourceEnum("source").notNull(), + status: recordingStatusEnum("status").notNull(), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + }, + (table) => ({ + recordedAtIdx: index("recordings_recorded_at_idx").on(table.recordedAt), + roomIdx: index("recordings_room_idx").on(table.roomId), + scheduleIdx: index("recordings_schedule_idx").on(table.scheduleId), + statusIdx: index("recordings_status_idx").on(table.status), + }), +); + +export const recordingJobs = pgTable( + "recording_jobs", + { + claimedBy: varchar("claimed_by", { length: 160 }), + command: jsonb("command").notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + failureReason: text("failure_reason"), + id: varchar("id", { length: 120 }).primaryKey(), + lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }).notNull(), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + startedAt: timestamp("started_at", { withTimezone: true }), + status: recordingJobStatusEnum("status").notNull(), + stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + leaseIdx: index("recording_jobs_lease_idx").on(table.status, table.leaseExpiresAt), + nodeStatusIdx: index("recording_jobs_node_status_idx").on(table.nodeId, table.status), + recordingIdx: index("recording_jobs_recording_idx").on(table.recordingId), + }), +); + +// One time-based segment of a recording. Many chunks belong to one recording + +// one job (`recordingId`/`jobId`); `index` is 1-based and `total` is filled in +// only once capture stops. Each chunk renders raw + enhanced and uploads as it +// closes, then fans out to its own per-destination upload_queue_items. +export const recordingChunks = pgTable( + "recording_chunks", + { + cachedAt: timestamp("cached_at", { withTimezone: true }), + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + durationSeconds: integer("duration_seconds").notNull().default(0), + enhancedCachePath: text("enhanced_cache_path"), + id: varchar("id", { length: 160 }).primaryKey(), + index: integer("index").notNull(), + jobId: varchar("job_id", { length: 160 }).notNull(), + offsetSeconds: integer("offset_seconds").notNull().default(0), + rawCachePath: text("raw_cache_path"), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + // A single chunk of multichannel/uncompressed audio (e.g. 32-ch X32 WAV) can + // exceed the 2 GiB signed-int32 ceiling, which threw "integer out of range" on + // upsert and failed the chunk. bigint (JS number, exact to 2^53 = 8 PiB) holds it. + sizeBytes: bigint("size_bytes", { mode: "number" }), + status: recordingChunkStatusEnum("status").notNull(), + total: integer("total"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + jobIdx: index("recording_chunks_job_idx").on(table.jobId), + recordingIdx: index("recording_chunks_recording_idx").on(table.recordingId), + recordingIndexUnique: uniqueIndex("recording_chunks_recording_index_unique").on( + table.recordingId, + table.index, + ), + }), +); diff --git a/packages/db/src/schema/room-roster.ts b/packages/db/src/schema/room-roster.ts new file mode 100644 index 00000000..844a10b2 --- /dev/null +++ b/packages/db/src/schema/room-roster.ts @@ -0,0 +1,42 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { roomRosterSourceEnum, roomRosterSubjectTypeEnum } from "./enums.js"; +import { rooms } from "./rooms.js"; +import { schedules } from "./schedules.js"; + +// Per-room access roster: one row per (room, subject, source[, sourceSchedule]). +// `capabilities` is the independently-toggled per-action set. Effective access +// for a user is the UNION of capabilities across their direct + group rows for a +// room. Uniqueness is enforced in the store (Postgres treats NULL +// sourceScheduleId as distinct, so a DB unique index would not dedupe manual rows). +export const roomRoster = pgTable( + "room_roster", + { + capabilities: jsonb("capabilities") + .notNull() + .default(sql`'[]'::jsonb`), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + roomId: varchar("room_id", { length: 160 }) + .notNull() + .references(() => rooms.id, { onDelete: "cascade" }), + source: roomRosterSourceEnum("source").notNull().default("manual"), + sourceScheduleId: varchar("source_schedule_id", { length: 160 }).references( + () => schedules.id, + { onDelete: "cascade" }, + ), + subjectId: varchar("subject_id", { length: 160 }).notNull(), + subjectType: roomRosterSubjectTypeEnum("subject_type").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + roomIdx: index("room_roster_room_idx").on(table.roomId), + scheduleIdx: index("room_roster_schedule_idx").on(table.sourceScheduleId), + subjectIdx: index("room_roster_subject_idx").on(table.subjectType, table.subjectId), + }), +); diff --git a/packages/db/src/schema/rooms.ts b/packages/db/src/schema/rooms.ts new file mode 100644 index 00000000..da270ae3 --- /dev/null +++ b/packages/db/src/schema/rooms.ts @@ -0,0 +1,22 @@ +import { index, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; + +// First-class room. `id` is the stable RBAC scope target (opaque); (site, name) +// is unique so backfill from node locations dedupes and renames don't collide. +export const rooms = pgTable( + "rooms", + { + building: varchar("building", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + description: text("description"), + floor: varchar("floor", { length: 160 }), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + notes: text("notes"), + site: varchar("site", { length: 160 }).notNull().default("Unknown Site"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + siteIdx: index("rooms_site_idx").on(table.site), + siteNameIdx: uniqueIndex("rooms_site_name_idx").on(table.site, table.name), + }), +); diff --git a/packages/db/src/schema/schedules.ts b/packages/db/src/schema/schedules.ts new file mode 100644 index 00000000..d31d28ef --- /dev/null +++ b/packages/db/src/schema/schedules.ts @@ -0,0 +1,63 @@ +import { sql } from "drizzle-orm"; +import { boolean, index, jsonb, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; + +import { rooms } from "./rooms.js"; + +export const schedules = pgTable( + "schedules", + { + // Access-group ids assigned to this schedule. Membership in an assigned + // group confers scoped RBAC (see the assignment capability bundle in the + // API) over the schedule's room. Groups are evaluated dynamically. + assignedGroupIds: jsonb("assigned_group_ids") + .notNull() + .default(sql`'[]'::jsonb`), + // User ids directly assigned to this schedule. Assignment confers scoped + // RBAC over the schedule's room without changing the user's role. + assignedUserIds: jsonb("assigned_user_ids") + .notNull() + .default(sql`'[]'::jsonb`), + captureBackend: varchar("capture_backend", { length: 32 }), + // Ordered 1-based source channel indices selected from the capture + // interface. Empty array = capture the whole interface (legacy behavior). + captureChannelSelection: jsonb("capture_channel_selection") + .notNull() + .default(sql`'[]'::jsonb`), + captureInterfaceId: varchar("capture_interface_id", { length: 160 }), + // Output mode applied to the selected channels (mono/stereo/etc). + channelMode: varchar("channel_mode", { length: 32 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + enabled: boolean("enabled").notNull().default(true), + folderTemplate: text("folder_template").notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + nextRunAt: timestamp("next_run_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }), + recurrence: jsonb("recurrence").notNull(), + recordingProfileId: varchar("recording_profile_id", { length: 160 }), + retentionPolicyId: varchar("retention_policy_id", { length: 160 }), + // Denormalized room display name/template value; retained for one release. + // roomId is the source of truth for room identity and RBAC scope. + room: varchar("room", { length: 160 }).notNull().default("Unknown Room"), + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "restrict", + }), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + timezone: varchar("timezone", { length: 80 }).notNull(), + titleTemplate: text("title_template").notNull(), + // Legacy single-policy column superseded by uploadPolicyIds; retained nullable + // for backfill and dropped in a later migration. + uploadPolicyId: varchar("upload_policy_id", { length: 160 }), + uploadPolicyIds: jsonb("upload_policy_ids") + .notNull() + .default(sql`'[]'::jsonb`), + watchdogPolicyId: varchar("watchdog_policy_id", { length: 160 }), + }, + (table) => ({ + enabledIdx: index("schedules_enabled_idx").on(table.enabled), + nodeIdx: index("schedules_node_idx").on(table.nodeId), + roomIdx: index("schedules_room_idx").on(table.roomId), + }), +); diff --git a/packages/db/src/schema/settings.ts b/packages/db/src/schema/settings.ts new file mode 100644 index 00000000..4bc719f2 --- /dev/null +++ b/packages/db/src/schema/settings.ts @@ -0,0 +1,40 @@ +import { sql } from "drizzle-orm"; +import { boolean, integer, jsonb, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const recordingProfiles = pgTable("recording_profiles", { + bitrateKbps: integer("bitrate_kbps").notNull(), + channelMode: varchar("channel_mode", { length: 64 }).notNull(), + codec: varchar("codec", { length: 32 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + settings: jsonb("settings") + .notNull() + .default(sql`'{}'::jsonb`), + silenceDetectionEnabled: boolean("silence_detection_enabled").notNull().default(false), + silenceSkipEnabled: boolean("silence_skip_enabled").notNull().default(false), + vbr: boolean("vbr").notNull().default(true), +}); + +export const controllerSettings = pgTable("controller_settings", { + controllerName: varchar("controller_name", { length: 160 }).notNull().default("Rakkr Controller"), + // Operator-chosen scheduling/ad-hoc defaults; null = no default set (forms + // fall back to the built-in profile/policy). Nullable, no FK: a referenced + // policy may be deleted, and the forms tolerate a stale id. + defaultRecordingProfileId: varchar("default_recording_profile_id", { length: 160 }), + defaultRetentionPolicyId: varchar("default_retention_policy_id", { length: 160 }), + defaultUploadPolicyId: varchar("default_upload_policy_id", { length: 160 }), + defaultWatchdogPolicyId: varchar("default_watchdog_policy_id", { length: 160 }), + id: varchar("id", { length: 64 }).primaryKey().default("controller"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + weekStartsOn: varchar("week_starts_on", { length: 16 }).notNull().default("monday"), +}); + +export const watchdogPolicies = pgTable("watchdog_policies", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + rules: jsonb("rules") + .notNull() + .default(sql`'[]'::jsonb`), +}); diff --git a/packages/db/src/schema/switchers.ts b/packages/db/src/schema/switchers.ts new file mode 100644 index 00000000..4e9e8e57 --- /dev/null +++ b/packages/db/src/schema/switchers.ts @@ -0,0 +1,83 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + jsonb, + pgTable, + primaryKey, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { rooms } from "./rooms.js"; + +// External audio matrix switcher (e.g. AVPro AC-MAX). Non-secret connection +// config is columnar; the optional control-channel password (for models whose +// control channel requires a login) is encrypted at rest in `secrets` via +// secret-box. `model`, `inputs`, and `outputs` are fixed at creation. +// `mode`: disabled (never connect) | observe (compute + audit, never send) | +// enforce (apply routing). +export const switchers = pgTable("switchers", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + host: varchar("host", { length: 255 }).notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + inputs: integer("inputs").notNull(), + mode: varchar("mode", { length: 16 }).notNull().default("observe"), + model: varchar("model", { length: 48 }).notNull(), + outputs: integer("outputs").notNull(), + port: integer("port").notNull(), + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + username: varchar("username", { length: 120 }), +}); + +// Room feed wired to a switcher input jack. One room per input and one input +// per room (per switcher): the composite PK enforces one row per input, the +// room unique index enforces the reverse. +export const switcherInputMap = pgTable( + "switcher_input_map", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + input: integer("input").notNull(), + roomId: varchar("room_id", { length: 160 }) + .notNull() + .references(() => rooms.id, { onDelete: "cascade" }), + switcherId: varchar("switcher_id", { length: 160 }) + .notNull() + .references(() => switchers.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.switcherId, table.input] }), + roomIdx: index("switcher_input_map_room_idx").on(table.roomId), + roomUnique: uniqueIndex("switcher_input_map_room_unique").on(table.switcherId, table.roomId), + }), +); + +// Listener desk wired to a switcher output jack. One user per output and one +// output per user (per switcher). +export const switcherOutputMap = pgTable( + "switcher_output_map", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + output: integer("output").notNull(), + switcherId: varchar("switcher_id", { length: 160 }) + .notNull() + .references(() => switchers.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.switcherId, table.output] }), + userIdx: index("switcher_output_map_user_idx").on(table.userId), + userUnique: uniqueIndex("switcher_output_map_user_unique").on(table.switcherId, table.userId), + }), +); diff --git a/packages/db/src/schema/uploads.ts b/packages/db/src/schema/uploads.ts new file mode 100644 index 00000000..6f2cb694 --- /dev/null +++ b/packages/db/src/schema/uploads.ts @@ -0,0 +1,99 @@ +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const uploadProviders = pgTable("upload_providers", { + // Non-secret typed connection config (smb/s3 fields). + config: jsonb("config") + .notNull() + .default(sql`'{}'::jsonb`), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + provider: varchar("provider", { length: 32 }).primaryKey(), + // Encrypted secret material (smbPassword, s3SecretAccessKey). + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// Named SMB/S3 upload targets. Unlike upload_providers (one row per kind), many +// destinations of each kind may exist; upload policies reference one by id. +export const uploadDestinations = pgTable("upload_destinations", { + // Non-secret typed connection config (smb/s3 fields). + config: jsonb("config") + .notNull() + .default(sql`'{}'::jsonb`), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + id: varchar("id", { length: 160 }).primaryKey(), + kind: varchar("kind", { length: 32 }).notNull(), + // Encrypted secret material (smbPassword, s3SecretAccessKey). + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const uploadPolicies = pgTable("upload_policies", { + deleteCacheAfterUpload: boolean("delete_cache_after_upload").notNull().default(false), + destinationId: varchar("destination_id", { length: 160 }), + enabled: boolean("enabled").notNull().default(false), + id: varchar("id", { length: 160 }).primaryKey(), + maxAttempts: integer("max_attempts").notNull().default(5), + name: varchar("name", { length: 160 }).notNull(), + pathOverride: text("path_override"), + // Legacy columns superseded by destinationId/pathOverride; retained nullable for + // backfill and dropped in a later migration. + provider: varchar("provider", { length: 32 }), + target: text("target"), + trigger: varchar("trigger", { length: 40 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const uploadQueueItems = pgTable( + "upload_queue_items", + { + attemptCount: integer("attempt_count").notNull().default(0), + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + // Chunk this upload item belongs to. NULL = legacy whole-recording item. + chunkId: varchar("chunk_id", { length: 160 }), + chunkIndex: integer("chunk_index"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + destinationId: varchar("destination_id", { length: 160 }), + fileName: text("file_name").notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + lastError: text("last_error"), + maxAttempts: integer("max_attempts").notNull().default(5), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(), + pathOverride: text("path_override"), + provider: varchar("provider", { length: 32 }).notNull(), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + status: varchar("status", { length: 32 }).notNull(), + target: text("target"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + uploadPolicyId: varchar("upload_policy_id", { length: 160 }), + }, + (table) => ({ + chunkIdx: index("upload_queue_items_chunk_idx").on(table.chunkId), + destinationStatusIdx: index("upload_queue_items_destination_status_idx").on( + table.destinationId, + table.status, + ), + dueIdx: index("upload_queue_items_due_idx").on(table.status, table.nextAttemptAt), + providerStatusIdx: index("upload_queue_items_provider_status_idx").on( + table.provider, + table.status, + ), + recordingIdx: index("upload_queue_items_recording_idx").on(table.recordingId), + }), +); From 4098838d229fd1f2f6eda441c9bbc504aab356f4 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 20:44:17 +0500 Subject: [PATCH 3/9] Extract API authorization + readiness helpers from composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/api/src/index.ts (921 LOC) drops to a thin wiring file by moving the audit/authorization closures (recordAuditEvent, requirePermission, permissionDecision, resourceScopeDecision, roomCapabilityAuthorizes, roster helpers, currentAuth/currentUser, requestContext) into a createAuthorization factory in index-authorization.ts and the /readyz DB probe into createReadinessProbe in index-readiness.ts — mirroring the existing createScopedResources/createResourceScopeTargets pattern. Exports (app, permissionDecision, runners) and startup side-effect order are unchanged; all 643 API tests pass. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/index-authorization.ts | 320 +++++++++++++++++++++++++ apps/api/src/index-readiness.ts | 42 ++++ apps/api/src/index.ts | 358 +++------------------------- 3 files changed, 391 insertions(+), 329 deletions(-) create mode 100644 apps/api/src/index-authorization.ts create mode 100644 apps/api/src/index-readiness.ts diff --git a/apps/api/src/index-authorization.ts b/apps/api/src/index-authorization.ts new file mode 100644 index 00000000..47c1762a --- /dev/null +++ b/apps/api/src/index-authorization.ts @@ -0,0 +1,320 @@ +import { randomUUID } from "node:crypto"; +import type { Context, MiddlewareHandler } from "hono"; + +import { + type AuditEvent, + type Permission, + permissionRequiresCapability, + type RoomCapability, +} from "@rakkr/shared"; + +import type { createAuditStore } from "./audit-store.js"; +import type { AuthResult, LocalAuthService } from "./auth-service.js"; +import type { AppBindings, AuditTarget, RecordAuditEvent } from "./http-types.js"; +import type { createResourceScopeTargets } from "./resource-scope-targets.js"; +import type { createRoomRosterStore } from "./room-roster-store.js"; + +export function requestContext(c: Context, sessionId?: string) { + return { + ipAddress: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip"), + sessionId: sessionId ?? c.req.header("x-rakkr-session-id"), + userAgent: c.req.header("user-agent"), + }; +} + +export function currentAuth(c: Context) { + return c.get("auth"); +} + +export function currentUser(c: Context) { + const user = currentAuth(c).user; + + if (!user) { + throw new Error("Authenticated route reached without a user"); + } + + return user; +} + +interface AuthorizationDependencies { + auditStore: ReturnType; + authService: LocalAuthService; + resourceScopeTargets: ReturnType; + roomRosterStore: ReturnType; +} + +// The audit + authorization closures shared by every route. Extracted from the +// composition root into a factory over the stores it needs so index.ts stays a +// thin wiring file; behavior and the role-scope OR room-capability decision are +// preserved exactly. +export function createAuthorization({ + auditStore, + authService, + resourceScopeTargets, + roomRosterStore, +}: AuthorizationDependencies) { + const recordAuditEvent: RecordAuditEvent = async (c, input) => { + const actor = + input.actor ?? + (input.auth?.user + ? { + id: input.auth.user.id, + name: input.auth.user.name, + roles: input.auth.user.roles, + type: "user" as const, + } + : { + id: "anonymous", + name: "Anonymous", + roles: [], + type: "user" as const, + }); + const event: AuditEvent = { + action: input.action, + actor, + actorContext: requestContext(c, input.auth?.sessionId), + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: { + method: c.req.method, + path: c.req.path, + ...input.details, + }, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; + + function requirePermission( + permission: Permission, + action: string, + target: (c: Context) => AuditTarget | Promise = () => ({ + type: "controller", + }), + ): MiddlewareHandler { + return async (c, next) => { + const auth = await authService.authenticate(c.req.header("authorization")); + const auditTarget = await target(c); + const decision = await permissionDecision(auth.user, permission, auditTarget); + const reason = decision.allowed + ? undefined + : authorizationReason({ + authenticated: Boolean(auth.user), + hasPermission: decision.hasPermission, + hasScope: decision.scope.allowed, + scopeReason: decision.scope.reason, + }); + + await recordAuditEvent(c, { + action, + auth, + details: { + grantedViaRoomCapability: decision.grantedViaRoomCapability, + requiredPermission: permission, + resourceScope: auditTarget, + resourceScopeDecision: decision.scope.reason, + roomCapability: decision.requiredCapability, + roles: auth.user?.roles ?? [], + }, + outcome: decision.allowed ? "allowed" : "denied", + permission, + reason, + target: auditTarget, + }); + + if (!decision.allowed) { + return c.json( + { + error: auth.user ? "Forbidden" : "Unauthorized", + permission, + }, + auth.user ? 403 : 401, + ); + } + + c.set("auth", auth); + await next(); + }; + } + + // The role-scope OR room-capability allow decision shared by requirePermission and + // the fine-grained per-room checks in recording/schedule handlers. A room roster + // (a manual grant or a calendar meeting-assignment) grants a per-action capability + // over its room even without the role permission — but never past an explicit deny. + // Exported for test coverage of the access-policy-DENY-beats-roster-grant + // precedence (the `scope.reason !== "access_policy_denied"` guard below). + async function permissionDecision( + user: AuthResult["user"], + permission: Permission, + target: AuditTarget, + ) { + const hasPermission = user?.permissions.includes(permission) ?? false; + const scope = user + ? await resourceScopeDecision(user, target) + : { allowed: false, reason: "unauthenticated" as const }; + let allowed = hasPermission && scope.allowed; + let grantedViaRoomCapability = false; + const requiredCapability = permissionRequiresCapability(permission); + + if (!allowed && user && scope.reason !== "access_policy_denied" && requiredCapability) { + grantedViaRoomCapability = await roomCapabilityAuthorizes(user, requiredCapability, target); + allowed = grantedViaRoomCapability; + } + + return { allowed, grantedViaRoomCapability, hasPermission, requiredCapability, scope }; + } + + // Whether a user is authorized for a permission against a target, reusing the same + // logic as requirePermission without auditing or an HTTP response. Used for the + // fine-grained per-room check once a request's room is resolved from its channels. + async function authorizeTargetForUser( + user: AuthResult["user"], + permission: Permission, + target: AuditTarget, + ) { + return (await permissionDecision(user, permission, target)).allowed; + } + + function authorizationReason(input: { + authenticated: boolean; + hasPermission: boolean; + hasScope: boolean; + scopeReason?: string; + }) { + if (!input.authenticated) { + return "unauthenticated"; + } + + if (!input.hasPermission) { + return "missing_permission"; + } + + if (!input.hasScope) { + return input.scopeReason ?? "missing_resource_scope"; + } + + return undefined; + } + + async function hasResourceScope(user: AuthResult["user"], target: AuditTarget) { + return (await resourceScopeDecision(user, target)).allowed; + } + + async function resourceScopeDecision(user: AuthResult["user"], target: AuditTarget) { + if (!user || !target.id) { + return { + allowed: Boolean(user), + reason: user ? undefined : "unauthenticated", + }; + } + + const targets = await resourceScopeTargets(target); + const policyDecision = await authService.accessPolicyDecision(user, targets); + + if (policyDecision?.effect === "deny") { + return { + allowed: false, + reason: "access_policy_denied", + }; + } + + if (user.roles.includes("owner") || user.roles.includes("admin")) { + return { + allowed: true, + reason: undefined, + }; + } + + if (policyDecision?.effect === "allow") { + return { + allowed: true, + reason: undefined, + }; + } + + const allowedByGrant = targets.some((candidate) => + user.resourceGrants.some( + (grant) => + (grant.resourceType === candidate.type || grant.resourceType === "*") && + (grant.resourceId === candidate.id || grant.resourceId === "*"), + ), + ); + + return { + allowed: allowedByGrant, + reason: allowedByGrant ? undefined : "missing_resource_scope", + }; + } + + function rosterSubject(user: NonNullable) { + return { groupIds: user.groups.map((group) => group.id), userId: user.id }; + } + + // Room ids a target resolves into (via the shared hierarchy expansion). + function roomIdsFromTargets(targets: AuditTarget[]) { + const roomIds: string[] = []; + + for (const target of targets) { + if (target.type === "room" && target.id) { + roomIds.push(target.id); + } + } + + return roomIds; + } + + // The set of room ids the user holds any capability in (direct or via a group). + async function rosterRoomIds(user: NonNullable) { + return new Set((await roomRosterStore.roomsForSubject(rosterSubject(user))).keys()); + } + + // True when the user holds `capability` in a room that `target` resolves into, + // via the room roster (a manual grant or a calendar meeting-assignment). Reuses + // the same hierarchy expansion as role scoping, so a room grant covers that + // room's nodes, interfaces, channels, schedules, and recordings. + async function roomCapabilityAuthorizes( + user: NonNullable, + capability: RoomCapability, + target: AuditTarget, + ) { + if (!target.id) { + return false; + } + + const roomIds = roomIdsFromTargets(await resourceScopeTargets(target)); + + if (roomIds.length === 0) { + return false; + } + + const subject = rosterSubject(user); + + for (const roomId of roomIds) { + const capabilities = await roomRosterStore.effectiveCapabilities(subject, roomId); + + if (capabilities.has(capability)) { + return true; + } + } + + return false; + } + + return { + authorizeTargetForUser, + hasResourceScope, + permissionDecision, + recordAuditEvent, + requirePermission, + rosterRoomIds, + }; +} diff --git a/apps/api/src/index-readiness.ts b/apps/api/src/index-readiness.ts new file mode 100644 index 00000000..7c2651eb --- /dev/null +++ b/apps/api/src/index-readiness.ts @@ -0,0 +1,42 @@ +import { createDatabase, sql } from "@rakkr/db"; + +const READINESS_DB_TIMEOUT_MS = 3000; + +// Readiness backing for /readyz. When DATABASE_URL is set we hold ONE dedicated +// health client and probe it with a bounded `select 1`; a hung DB must not hang +// the probe, so the query races a short timeout. When DATABASE_URL is unset +// (memory-store / test mode) there is no DB to depend on, so the controller is +// always ready. +export function createReadinessProbe(databaseUrl: string | undefined) { + const readinessDatabase = databaseUrl ? createDatabase(databaseUrl) : undefined; + + async function checkDatabaseReady(): Promise { + if (!readinessDatabase) { + return true; + } + + let timer: ReturnType | undefined; + + try { + const probe = readinessDatabase.execute(sql`select 1`); + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("database readiness probe timed out")), + READINESS_DB_TIMEOUT_MS, + ); + }); + + await Promise.race([probe, timeout]); + + return true; + } catch { + return false; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + return { checkDatabaseReady }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 33d4c512..298d1460 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,35 +1,31 @@ import { serve } from "@hono/node-server"; -import { createDatabase, sql } from "@rakkr/db"; import "dotenv/config"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; -import { randomUUID } from "node:crypto"; import { z } from "zod"; -import type { Context, MiddlewareHandler } from "hono"; import { registerAgentMonitorRoutes } from "./agent-monitor-routes.js"; import { registerAgentRoutes } from "./agent-routes.js"; import { agentReleaseService } from "./agent-release-service.js"; import { createApiRunners, startApiRunners } from "./api-runners.js"; -import { - type AuditEvent, - defaultCalendarGrantCapabilities, - type Permission, - permissionRequiresCapability, - type RoomCapability, - type ScheduleSummary, -} from "@rakkr/shared"; +import { defaultCalendarGrantCapabilities, type ScheduleSummary } from "@rakkr/shared"; import { registerAuditRoutes } from "./audit-routes.js"; import { createAuditStore } from "./audit-store.js"; import { registerAuthLifecycleRoutes } from "./auth-lifecycle-routes.js"; import { registerAuthManagementRoutes } from "./auth-management-routes.js"; import { clearOidcLoginStateCookie, registerAuthOidcRoutes } from "./auth-oidc-routes.js"; -import { AuthError, LocalAuthService, type AuthResult } from "./auth-service.js"; +import { AuthError, LocalAuthService } from "./auth-service.js"; import { registerHealthRoutes } from "./health-routes.js"; import { createHealthEventStore } from "./health-store.js"; -import type { RecordAuditEvent } from "./http-types.js"; +import { + createAuthorization, + currentAuth, + currentUser, + requestContext, +} from "./index-authorization.js"; +import { createReadinessProbe } from "./index-readiness.js"; import { nodes as seedNodes, recordings, schedules as seedSchedules } from "./demo-data.js"; -import type { AppBindings, AuditTarget } from "./http-types.js"; +import type { AppBindings } from "./http-types.js"; import { createListenMonitorStore } from "./listen-monitor-store.js"; import { createListenSessionStore } from "./listen-session-store.js"; import { createMeterFrameStore } from "./meter-store.js"; @@ -68,42 +64,7 @@ const startedAt = new Date(); const port = Number(process.env.PORT ?? 8787); const webOrigin = process.env.RAKKR_WEB_ORIGIN ?? "http://localhost:5173"; -// Readiness backing for /readyz. When DATABASE_URL is set we hold ONE dedicated -// health client and probe it with a bounded `select 1`; a hung DB must not hang -// the probe, so the query races a short timeout. When DATABASE_URL is unset -// (memory-store / test mode) there is no DB to depend on, so the controller is -// always ready. -const readinessDatabaseUrl = process.env.DATABASE_URL; -const readinessDatabase = readinessDatabaseUrl ? createDatabase(readinessDatabaseUrl) : undefined; -const READINESS_DB_TIMEOUT_MS = 3000; - -async function checkDatabaseReady(): Promise { - if (!readinessDatabase) { - return true; - } - - let timer: ReturnType | undefined; - - try { - const probe = readinessDatabase.execute(sql`select 1`); - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error("database readiness probe timed out")), - READINESS_DB_TIMEOUT_MS, - ); - }); - - await Promise.race([probe, timeout]); - - return true; - } catch { - return false; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} +const { checkDatabaseReady } = createReadinessProbe(process.env.DATABASE_URL); const auditStore = createAuditStore(); const authService = new LocalAuthService(); @@ -169,216 +130,6 @@ const loginRequestSchema = z.object({ password: z.string().min(1), }); -function requestContext(c: Context, sessionId?: string) { - return { - ipAddress: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip"), - sessionId: sessionId ?? c.req.header("x-rakkr-session-id"), - userAgent: c.req.header("user-agent"), - }; -} - -const recordAuditEvent: RecordAuditEvent = async (c, input) => { - const actor = - input.actor ?? - (input.auth?.user - ? { - id: input.auth.user.id, - name: input.auth.user.name, - roles: input.auth.user.roles, - type: "user" as const, - } - : { - id: "anonymous", - name: "Anonymous", - roles: [], - type: "user" as const, - }); - const event: AuditEvent = { - action: input.action, - actor, - actorContext: requestContext(c, input.auth?.sessionId), - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: { - method: c.req.method, - path: c.req.path, - ...input.details, - }, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; -}; - -function requirePermission( - permission: Permission, - action: string, - target: (c: Context) => AuditTarget | Promise = () => ({ - type: "controller", - }), -): MiddlewareHandler { - return async (c, next) => { - const auth = await authService.authenticate(c.req.header("authorization")); - const auditTarget = await target(c); - const decision = await permissionDecision(auth.user, permission, auditTarget); - const reason = decision.allowed - ? undefined - : authorizationReason({ - authenticated: Boolean(auth.user), - hasPermission: decision.hasPermission, - hasScope: decision.scope.allowed, - scopeReason: decision.scope.reason, - }); - - await recordAuditEvent(c, { - action, - auth, - details: { - grantedViaRoomCapability: decision.grantedViaRoomCapability, - requiredPermission: permission, - resourceScope: auditTarget, - resourceScopeDecision: decision.scope.reason, - roomCapability: decision.requiredCapability, - roles: auth.user?.roles ?? [], - }, - outcome: decision.allowed ? "allowed" : "denied", - permission, - reason, - target: auditTarget, - }); - - if (!decision.allowed) { - return c.json( - { - error: auth.user ? "Forbidden" : "Unauthorized", - permission, - }, - auth.user ? 403 : 401, - ); - } - - c.set("auth", auth); - await next(); - }; -} - -// The role-scope OR room-capability allow decision shared by requirePermission and -// the fine-grained per-room checks in recording/schedule handlers. A room roster -// (a manual grant or a calendar meeting-assignment) grants a per-action capability -// over its room even without the role permission — but never past an explicit deny. -// Exported for test coverage of the access-policy-DENY-beats-roster-grant -// precedence (the `scope.reason !== "access_policy_denied"` guard below). -export async function permissionDecision( - user: AuthResult["user"], - permission: Permission, - target: AuditTarget, -) { - const hasPermission = user?.permissions.includes(permission) ?? false; - const scope = user - ? await resourceScopeDecision(user, target) - : { allowed: false, reason: "unauthenticated" as const }; - let allowed = hasPermission && scope.allowed; - let grantedViaRoomCapability = false; - const requiredCapability = permissionRequiresCapability(permission); - - if (!allowed && user && scope.reason !== "access_policy_denied" && requiredCapability) { - grantedViaRoomCapability = await roomCapabilityAuthorizes(user, requiredCapability, target); - allowed = grantedViaRoomCapability; - } - - return { allowed, grantedViaRoomCapability, hasPermission, requiredCapability, scope }; -} - -// Whether a user is authorized for a permission against a target, reusing the same -// logic as requirePermission without auditing or an HTTP response. Used for the -// fine-grained per-room check once a request's room is resolved from its channels. -async function authorizeTargetForUser( - user: AuthResult["user"], - permission: Permission, - target: AuditTarget, -) { - return (await permissionDecision(user, permission, target)).allowed; -} - -function authorizationReason(input: { - authenticated: boolean; - hasPermission: boolean; - hasScope: boolean; - scopeReason?: string; -}) { - if (!input.authenticated) { - return "unauthenticated"; - } - - if (!input.hasPermission) { - return "missing_permission"; - } - - if (!input.hasScope) { - return input.scopeReason ?? "missing_resource_scope"; - } - - return undefined; -} - -async function hasResourceScope(user: AuthResult["user"], target: AuditTarget) { - return (await resourceScopeDecision(user, target)).allowed; -} - -async function resourceScopeDecision(user: AuthResult["user"], target: AuditTarget) { - if (!user || !target.id) { - return { - allowed: Boolean(user), - reason: user ? undefined : "unauthenticated", - }; - } - - const targets = await resourceScopeTargets(target); - const policyDecision = await authService.accessPolicyDecision(user, targets); - - if (policyDecision?.effect === "deny") { - return { - allowed: false, - reason: "access_policy_denied", - }; - } - - if (user.roles.includes("owner") || user.roles.includes("admin")) { - return { - allowed: true, - reason: undefined, - }; - } - - if (policyDecision?.effect === "allow") { - return { - allowed: true, - reason: undefined, - }; - } - - const allowedByGrant = targets.some((candidate) => - user.resourceGrants.some( - (grant) => - (grant.resourceType === candidate.type || grant.resourceType === "*") && - (grant.resourceId === candidate.id || grant.resourceId === "*"), - ), - ); - - return { - allowed: allowedByGrant, - reason: allowedByGrant ? undefined : "missing_resource_scope", - }; -} - const resourceScopeTargets = createResourceScopeTargets({ healthEventStore, nodeStore, @@ -386,78 +137,27 @@ const resourceScopeTargets = createResourceScopeTargets({ scheduleStore, }); -function rosterSubject(user: NonNullable) { - return { groupIds: user.groups.map((group) => group.id), userId: user.id }; -} - -// Room ids a target resolves into (via the shared hierarchy expansion). -function roomIdsFromTargets(targets: AuditTarget[]) { - const roomIds: string[] = []; - - for (const target of targets) { - if (target.type === "room" && target.id) { - roomIds.push(target.id); - } - } - - return roomIds; -} - -// The set of room ids the user holds any capability in (direct or via a group). -async function rosterRoomIds(user: NonNullable) { - return new Set((await roomRosterStore.roomsForSubject(rosterSubject(user))).keys()); -} - -// True when the user holds `capability` in a room that `target` resolves into, -// via the room roster (a manual grant or a calendar meeting-assignment). Reuses -// the same hierarchy expansion as role scoping, so a room grant covers that -// room's nodes, interfaces, channels, schedules, and recordings. -async function roomCapabilityAuthorizes( - user: NonNullable, - capability: RoomCapability, - target: AuditTarget, -) { - if (!target.id) { - return false; - } - - const roomIds = roomIdsFromTargets(await resourceScopeTargets(target)); - - if (roomIds.length === 0) { - return false; - } - - const subject = rosterSubject(user); - - for (const roomId of roomIds) { - const capabilities = await roomRosterStore.effectiveCapabilities(subject, roomId); - - if (capabilities.has(capability)) { - return true; - } - } - - return false; -} - -function currentAuth(c: Context) { - return c.get("auth"); -} - -function currentUser(c: Context) { - const user = currentAuth(c).user; - - if (!user) { - throw new Error("Authenticated route reached without a user"); - } - - return user; -} +const authorization = createAuthorization({ + auditStore, + authService, + resourceScopeTargets, + roomRosterStore, +}); +// Re-exported for test coverage of the access-policy-DENY-beats-roster-grant +// precedence; see createAuthorization in index-authorization.ts. +export const permissionDecision = authorization.permissionDecision; +const { + authorizeTargetForUser, + hasResourceScope, + recordAuditEvent, + requirePermission, + rosterRoomIds, +} = authorization; // Per-user resource visibility + meter/monitor access decisions. Extracted to a // sibling factory to keep this composition root within the LOC budget; the -// factory only builds closures over the roster/scope helpers above, so wiring it -// here preserves behavior and the startup order of side effects exactly. +// factory only builds closures over the authorization + roster/scope helpers, so +// wiring it here preserves behavior and the startup order of side effects exactly. const { canServeWholeNodeMonitor, filterMeterFrameForUser, From 31ea00f036501f282616d11284debec1b3966054 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 20:53:23 +0500 Subject: [PATCH 4/9] Split recorder-agent inventory.rs into cohesive submodules The 917-line inventory.rs becomes a ~135-line facade (public NodeInventory shapes + collect/heartbeat_snapshot/heartbeat_health_details) delegating to submodules under src/inventory/: alsa.rs (ALSA interface discovery, parsing, metadata, identity, serial), runtime.rs (host runtime facts + backend detection), and net.rs (IP-address collection). Unit tests move next to the code they cover. Public API is unchanged; cargo check/clippy/fmt clean and all 19 inventory tests pass. Co-Authored-By: Claude Opus 4.8 --- crates/recorder-agent/src/inventory.rs | 812 +----------------- crates/recorder-agent/src/inventory/alsa.rs | 570 ++++++++++++ crates/recorder-agent/src/inventory/net.rs | 60 ++ .../recorder-agent/src/inventory/runtime.rs | 190 ++++ 4 files changed, 828 insertions(+), 804 deletions(-) create mode 100644 crates/recorder-agent/src/inventory/alsa.rs create mode 100644 crates/recorder-agent/src/inventory/net.rs create mode 100644 crates/recorder-agent/src/inventory/runtime.rs diff --git a/crates/recorder-agent/src/inventory.rs b/crates/recorder-agent/src/inventory.rs index a7e7c274..1c9491b8 100644 --- a/crates/recorder-agent/src/inventory.rs +++ b/crates/recorder-agent/src/inventory.rs @@ -1,12 +1,12 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::{env, fs}; - use serde::Serialize; use crate::config::AgentConfig; use crate::telemetry::now_rfc3339; +mod alsa; +mod net; +mod runtime; + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NodeInventory { @@ -74,8 +74,8 @@ pub fn collect(config: &AgentConfig) -> NodeInventory { .and_then(|value| value.into_string().ok()) .unwrap_or_else(|| "unknown-host".to_string()); - let interfaces = discover_audio_interfaces(config); - let runtime = runtime_details(&interfaces); + let interfaces = alsa::discover_audio_interfaces(config); + let runtime = runtime::runtime_details(&interfaces); NodeInventory { agent_version: crate::version::AGENT_VERSION.to_string(), @@ -83,7 +83,7 @@ pub fn collect(config: &AgentConfig) -> NodeInventory { hostname, id: config.node_id.clone(), interfaces, - ip_addresses: collect_ip_addresses(), + ip_addresses: net::collect_ip_addresses(), last_seen_at: now_rfc3339(), location: NodeLocation { room: config.room.clone(), @@ -99,7 +99,7 @@ pub fn heartbeat_snapshot(inventory: &NodeInventory) -> NodeInventory { let mut snapshot = inventory.clone(); snapshot.last_seen_at = now_rfc3339(); - snapshot.runtime = runtime_details(&snapshot.interfaces); + snapshot.runtime = runtime::runtime_details(&snapshot.interfaces); snapshot } @@ -119,799 +119,3 @@ pub fn heartbeat_health_details( "status": heartbeat.status.as_str(), }) } - -fn discover_audio_interfaces(config: &AgentConfig) -> Vec { - let mut alsa_interfaces = discover_arecord_interfaces(&config.inventory_arecord_command); - - if alsa_interfaces.is_empty() { - alsa_interfaces = discover_proc_asound_interfaces( - &config.inventory_proc_asound_pcm_path, - &config.inventory_arecord_command, - ); - } - - if alsa_interfaces.is_empty() { - vec![fallback_interface()] - } else { - alsa_interfaces - } -} - -fn discover_arecord_interfaces(arecord_command: &str) -> Vec { - let Ok(output) = Command::new(arecord_command).arg("-l").output() else { - return Vec::new(); - }; - - if !output.status.success() { - return Vec::new(); - } - - let list_output = String::from_utf8_lossy(&output.stdout); - - parse_alsa_capture_devices(&list_output) - .into_iter() - .map(|device| { - let metadata = read_alsa_metadata(device.card, device.device, arecord_command); - let channel_count = metadata.channel_count.unwrap_or(2); - let sample_rates = if metadata.sample_rates.is_empty() { - vec![48_000] - } else { - metadata.sample_rates - }; - - AudioInterfaceInventory { - alias: device.system_name.clone(), - backend: "alsa".to_string(), - channel_count, - channels: channels(channel_count), - hardware_path: Some(alsa_hardware_path(device.card, device.device)), - sample_rates, - serial_number: alsa_serial_number(device.card), - id: alsa_interface_id(&device), - system_name: device.system_name, - system_ref: Some(device.system_ref), - } - }) - .collect() -} - -fn discover_proc_asound_interfaces( - path: &Path, - arecord_command: &str, -) -> Vec { - fs::read_to_string(path) - .map(|content| { - parse_proc_asound_pcm_devices(&content) - .into_iter() - .map(|device| { - let metadata = read_alsa_metadata(device.card, device.device, arecord_command); - let channel_count = metadata.channel_count.unwrap_or(2); - let sample_rates = if metadata.sample_rates.is_empty() { - vec![48_000] - } else { - metadata.sample_rates - }; - - AudioInterfaceInventory { - alias: device.system_name.clone(), - backend: "alsa".to_string(), - channel_count, - channels: channels(channel_count), - hardware_path: Some(alsa_hardware_path(device.card, device.device)), - id: format!("alsa_hw_{}_{}", device.card, device.device), - sample_rates, - serial_number: alsa_serial_number(device.card), - system_name: device.system_name, - system_ref: Some(device.system_ref), - } - }) - .collect() - }) - .unwrap_or_default() -} - -// The controller caps a heartbeat's `ipAddresses` at 16 (nodeHeartbeatSchema) -// and truncates an over-cap list rather than reject it. A well-behaved agent -// should never emit more than the cap in the first place, so bound the list -// here too (a multi-homed host — IPv6 SLAAC/privacy + Docker/libvirt/VLAN -// bridges — can exceed 16). See audit R7-IP-AGENT-CAP / R7-IPCAP. -const MAX_IP_ADDRESSES: usize = 16; - -fn collect_ip_addresses() -> Vec { - let Ok(output) = Command::new("hostname").arg("-I").output() else { - return Vec::new(); - }; - - if !output.status.success() { - return Vec::new(); - } - - parse_ip_addresses(&String::from_utf8_lossy(&output.stdout)) -} - -// Pure parser for `hostname -I` output: whitespace-split, bounded to the -// documented cap so the agent never sends a payload the controller would have -// to truncate. -fn parse_ip_addresses(stdout: &str) -> Vec { - stdout - .split_whitespace() - .take(MAX_IP_ADDRESSES) - .map(str::to_string) - .collect() -} - -fn fallback_interface() -> AudioInterfaceInventory { - AudioInterfaceInventory { - alias: "Default Capture Interface".to_string(), - backend: "unknown".to_string(), - channel_count: 2, - channels: channels(2), - hardware_path: None, - id: "iface_default_capture".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "Audio backend discovery pending".to_string(), - system_ref: None, - } -} - -fn alsa_hardware_path(card: u16, device: u16) -> String { - canonical_path(format!("/sys/class/sound/card{card}/pcmC{card}D{device}c")) - .or_else(|| canonical_path(format!("/sys/class/sound/card{card}/device"))) - .unwrap_or_else(|| PathBuf::from(format!("/proc/asound/card{card}/pcm{device}c"))) - .to_string_lossy() - .to_string() -} - -fn alsa_interface_id(device: &AlsaCaptureDevice) -> String { - let Some(card_id) = alsa_card_id(&device.system_ref) else { - return format!("alsa_hw_{}_{}", device.card, device.device); - }; - let normalized = normalize_inventory_id_token(&card_id); - - if normalized.is_empty() { - format!("alsa_hw_{}_{}", device.card, device.device) - } else { - format!("alsa_card_{normalized}_dev_{}", device.device) - } -} - -fn alsa_card_id(system_ref: &str) -> Option { - system_ref - .strip_prefix("hw:")? - .split(',') - .find_map(|part| part.trim().strip_prefix("CARD=").map(str::to_string)) -} - -fn normalize_inventory_id_token(value: &str) -> String { - value - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn alsa_serial_number(card: u16) -> Option { - let device_path = canonical_path(format!("/sys/class/sound/card{card}/device"))?; - - sysfs_serial_number(&device_path) -} - -fn sysfs_serial_number(path: &Path) -> Option { - path.ancestors().find_map(|candidate| { - read_trimmed(candidate.join("serial")) - .or_else(|| serial_from_uevent(&read_trimmed(candidate.join("uevent"))?)) - }) -} - -fn canonical_path(path: impl AsRef) -> Option { - fs::canonicalize(path).ok() -} - -fn read_trimmed(path: impl AsRef) -> Option { - let value = fs::read_to_string(path).ok()?.trim().to_string(); - - if value.is_empty() { None } else { Some(value) } -} - -fn serial_from_uevent(value: &str) -> Option { - value.lines().find_map(|line| { - let serial = line.strip_prefix("SERIAL=")?.trim(); - - if serial.is_empty() { - None - } else { - Some(serial.to_string()) - } - }) -} - -fn runtime_details(interfaces: &[AudioInterfaceInventory]) -> NodeRuntime { - NodeRuntime { - architecture: std::env::consts::ARCH.to_string(), - audio_backends: runtime_audio_backends(interfaces, command_available), - kernel_release: command_stdout("uname", &["-r"]), - os_name: linux_pretty_name().or_else(|| command_stdout("uname", &["-s"])), - uptime_seconds: linux_uptime_seconds(), - } -} - -fn runtime_audio_backends( - interfaces: &[AudioInterfaceInventory], - mut is_command_available: impl FnMut(&str) -> bool, -) -> Vec { - let mut audio_backends = interfaces - .iter() - .map(|audio_interface| audio_interface.backend.clone()) - .collect::>(); - - if is_command_available("pw-cli") || is_command_available("pipewire") { - audio_backends.push("pipewire".to_string()); - } - - if is_command_available("jackd") || is_command_available("jack_control") { - audio_backends.push("jack".to_string()); - } - - if audio_backends.iter().any(|backend| backend != "unknown") { - audio_backends.retain(|backend| backend != "unknown"); - } - - audio_backends.sort(); - audio_backends.dedup(); - audio_backends -} - -fn command_available(command: &str) -> bool { - let Some(paths) = env::var_os("PATH") else { - return false; - }; - let extensions = command_extensions(); - - env::split_paths(&paths).any(|path| { - extensions - .iter() - .map(|extension| path.join(format!("{command}{extension}"))) - .any(|candidate| candidate.is_file()) - }) -} - -fn command_extensions() -> Vec { - if cfg!(windows) { - env::var_os("PATHEXT") - .map(|value| { - let mut extensions = vec!["".to_string()]; - extensions.extend( - value - .to_string_lossy() - .split(';') - .map(str::trim) - .filter(|extension| !extension.is_empty()) - .map(str::to_string), - ); - - extensions - }) - .filter(|extensions| extensions.len() > 1) - .unwrap_or_else(|| { - vec![ - "".to_string(), - ".exe".to_string(), - ".cmd".to_string(), - ".bat".to_string(), - ] - }) - } else { - vec!["".to_string()] - } -} - -fn command_stdout(command: &str, args: &[&str]) -> Option { - let output = Command::new(command).args(args).output().ok()?; - - if !output.status.success() { - return None; - } - - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - if value.is_empty() { None } else { Some(value) } -} - -fn linux_pretty_name() -> Option { - let content = fs::read_to_string("/etc/os-release").ok()?; - - content.lines().find_map(|line| { - let value = line.strip_prefix("PRETTY_NAME=")?; - - Some(value.trim_matches('"').to_string()) - }) -} - -fn linux_uptime_seconds() -> Option { - let content = fs::read_to_string("/proc/uptime").ok()?; - let first = content.split_whitespace().next()?; - let seconds = first.split('.').next()?.parse::().ok()?; - - Some(seconds) -} - -fn channels(channel_count: u16) -> Vec { - (1..=channel_count) - .map(|index| AudioChannelInventory { - alias: format!("Input {index}"), - index, - }) - .collect() -} - -#[derive(Debug, PartialEq, Eq)] -struct AlsaCaptureDevice { - card: u16, - device: u16, - system_ref: String, - system_name: String, -} - -fn parse_alsa_capture_devices(input: &str) -> Vec { - input - .lines() - .filter_map(parse_alsa_capture_device) - .collect() -} - -fn parse_proc_asound_pcm_devices(input: &str) -> Vec { - input - .lines() - .filter_map(parse_proc_asound_pcm_device) - .collect() -} - -fn parse_alsa_capture_device(line: &str) -> Option { - let after_card = line.trim().strip_prefix("card ")?; - let (card_text, after_card_number) = after_card.split_once(':')?; - let card = card_text.trim().parse::().ok()?; - let (card_label_text, after_device_marker) = after_card_number.split_once(", device ")?; - let (device_text, device_label_text) = after_device_marker.split_once(':')?; - let device = device_text.trim().parse::().ok()?; - let card_id = card_label_text.split_whitespace().next()?; - let card_label = bracket_label(card_label_text).unwrap_or_else(|| card_label_text.trim()); - let device_label = bracket_label(device_label_text).unwrap_or_else(|| device_label_text.trim()); - let system_name = if card_label.eq_ignore_ascii_case(device_label) { - device_label.to_string() - } else { - format!("{card_label} {device_label}") - }; - - Some(AlsaCaptureDevice { - card, - device, - system_ref: format!("hw:CARD={card_id},DEV={device}"), - system_name, - }) -} - -fn parse_proc_asound_pcm_device(line: &str) -> Option { - if !line.contains(": capture") { - return None; - } - - let (address, rest) = line.split_once(':')?; - let (card_text, device_text) = address.trim().split_once('-')?; - let card = card_text.trim().parse::().ok()?; - let device = device_text.trim().parse::().ok()?; - let system_name = rest - .split(':') - .map(str::trim) - .find(|part| { - !part.is_empty() && !part.starts_with("playback") && !part.starts_with("capture") - })? - .to_string(); - - Some(AlsaCaptureDevice { - card, - device, - system_ref: format!("hw:{card},{device}"), - system_name, - }) -} - -fn bracket_label(value: &str) -> Option<&str> { - let (_, after_open) = value.split_once('[')?; - let (label, _) = after_open.split_once(']')?; - Some(label.trim()) -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct AlsaStreamMetadata { - channel_count: Option, - sample_rates: Vec, -} - -fn read_alsa_stream_metadata(card: u16) -> AlsaStreamMetadata { - fs::read_to_string(format!("/proc/asound/card{card}/stream0")) - .map(|content| parse_alsa_stream_metadata(&content)) - .unwrap_or_default() -} - -fn read_alsa_metadata(card: u16, device: u16, arecord_command: &str) -> AlsaStreamMetadata { - let metadata = read_alsa_stream_metadata(card); - - if metadata.is_complete() { - metadata - } else { - metadata.with_fallback(read_alsa_hw_params_metadata(card, device, arecord_command)) - } -} - -fn read_alsa_hw_params_metadata( - card: u16, - device: u16, - arecord_command: &str, -) -> AlsaStreamMetadata { - let Ok(output) = Command::new(arecord_command) - .arg("-D") - .arg(format!("hw:{card},{device}")) - .arg("--dump-hw-params") - .arg("-f") - .arg("S16_LE") - .arg("-r") - .arg("48000") - .arg("-c") - .arg("2") - .arg("-d") - .arg("1") - .arg("/dev/null") - .output() - else { - return AlsaStreamMetadata::default(); - }; - - if !output.status.success() { - return AlsaStreamMetadata::default(); - } - - let mut content = String::from_utf8_lossy(&output.stdout).to_string(); - content.push_str(&String::from_utf8_lossy(&output.stderr)); - - parse_alsa_hw_params_metadata(&content) -} - -fn parse_alsa_stream_metadata(input: &str) -> AlsaStreamMetadata { - let mut metadata = AlsaStreamMetadata::default(); - let mut in_capture = false; - - for line in input.lines() { - let trimmed = line.trim(); - - if trimmed == "Capture:" { - in_capture = true; - continue; - } - - if trimmed.ends_with(':') && trimmed != "Capture:" { - in_capture = false; - } - - if !in_capture { - continue; - } - - if let Some(value) = trimmed.strip_prefix("Channels:") { - metadata.channel_count = parse_numbers::(value).into_iter().max(); - } else if let Some(value) = trimmed.strip_prefix("Rates:") { - metadata.sample_rates = parse_numbers(value); - } - } - - metadata.sample_rates.sort_unstable(); - metadata.sample_rates.dedup(); - metadata -} - -fn parse_alsa_hw_params_metadata(input: &str) -> AlsaStreamMetadata { - let mut metadata = AlsaStreamMetadata::default(); - - for line in input.lines() { - let trimmed = line.trim(); - - if let Some(value) = trimmed.strip_prefix("CHANNELS:") { - metadata.channel_count = parse_numbers::(value).into_iter().max(); - } else if let Some(value) = trimmed.strip_prefix("RATE:") { - metadata.sample_rates = parse_numbers(value); - } - } - - metadata.sample_rates.sort_unstable(); - metadata.sample_rates.dedup(); - metadata -} - -impl AlsaStreamMetadata { - fn is_complete(&self) -> bool { - self.channel_count.is_some() && !self.sample_rates.is_empty() - } - - fn with_fallback(mut self, fallback: Self) -> Self { - if self.channel_count.is_none() { - self.channel_count = fallback.channel_count; - } - - if self.sample_rates.is_empty() { - self.sample_rates = fallback.sample_rates; - } - - self - } -} - -fn parse_numbers(value: &str) -> Vec -where - T: std::str::FromStr, -{ - value - .split(|character: char| !character.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse::().ok()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_ip_addresses_and_caps_at_the_documented_limit() { - // A normal multi-address host parses to a trimmed list. - assert_eq!( - parse_ip_addresses("192.168.1.10 10.0.0.5 \n"), - vec!["192.168.1.10".to_string(), "10.0.0.5".to_string()], - ); - - // A host with more than the cap (e.g. IPv6 SLAAC/privacy + bridges) is - // bounded to MAX_IP_ADDRESSES so the controller never has to truncate. - let many = (0..40) - .map(|n| format!("10.0.0.{n}")) - .collect::>() - .join(" "); - let parsed = parse_ip_addresses(&many); - - assert_eq!(parsed.len(), MAX_IP_ADDRESSES); - assert_eq!(parsed.first().map(String::as_str), Some("10.0.0.0")); - assert_eq!(parsed.last().map(String::as_str), Some("10.0.0.15")); - - // Empty output yields no addresses. - assert!(parse_ip_addresses(" \n").is_empty()); - } - - #[test] - fn parses_arecord_capture_devices() { - let devices = parse_alsa_capture_devices( - r#" -**** List of CAPTURE Hardware Devices **** -card 2: XUSB [X-USB], device 0: USB Audio [USB Audio] - Subdevices: 1/1 - Subdevice #0: subdevice #0 -"#, - ); - - assert_eq!( - devices, - vec![AlsaCaptureDevice { - card: 2, - device: 0, - system_ref: "hw:CARD=XUSB,DEV=0".to_string(), - system_name: "X-USB USB Audio".to_string(), - }] - ); - } - - #[test] - fn stable_alsa_interface_id_uses_card_name_when_available() { - let device = AlsaCaptureDevice { - card: 7, - device: 0, - system_ref: "hw:CARD=SMOKE,DEV=0".to_string(), - system_name: "Smoke Audio Capture".to_string(), - }; - - assert_eq!(alsa_interface_id(&device), "alsa_card_smoke_dev_0"); - } - - #[test] - fn parses_capture_stream_metadata() { - let metadata = parse_alsa_stream_metadata( - r#" -Playback: - Channels: 2 - Rates: 48000 -Capture: - Channels: 32 - Rates: 44100, 48000 -"#, - ); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![44_100, 48_000], - } - ); - } - - #[test] - fn parses_arecord_hw_params_metadata() { - let metadata = parse_alsa_hw_params_metadata( - r#" -HW Params of device "hw:1,0": --------------------- -ACCESS: MMAP_INTERLEAVED RW_INTERLEAVED -FORMAT: S16_LE S24_LE S32_LE -CHANNELS: [1 32] -RATE: [44100 96000] -PERIOD_SIZE: [32 8192] -"#, - ); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![44_100, 96_000], - } - ); - } - - #[test] - fn keeps_proc_stream_metadata_before_hw_params_fallback() { - let metadata = AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![48_000], - } - .with_fallback(AlsaStreamMetadata { - channel_count: Some(2), - sample_rates: vec![44_100], - }); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![48_000], - } - ); - } - - #[test] - fn fills_missing_stream_metadata_from_hw_params_fallback() { - let metadata = AlsaStreamMetadata { - channel_count: None, - sample_rates: Vec::new(), - } - .with_fallback(AlsaStreamMetadata { - channel_count: Some(8), - sample_rates: vec![44_100, 48_000], - }); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(8), - sample_rates: vec![44_100, 48_000], - } - ); - } - - #[test] - fn parses_proc_asound_pcm_capture_devices() { - let devices = parse_proc_asound_pcm_devices( - r#" -00-00: ALC256 Analog : ALC256 Analog : playback 1 : capture 1 -00-03: HDMI 0 : HDMI 0 : playback 1 -01-00: USB Audio : USB Audio : capture 1 -"#, - ); - - assert_eq!( - devices, - vec![ - AlsaCaptureDevice { - card: 0, - device: 0, - system_ref: "hw:0,0".to_string(), - system_name: "ALC256 Analog".to_string(), - }, - AlsaCaptureDevice { - card: 1, - device: 0, - system_ref: "hw:1,0".to_string(), - system_name: "USB Audio".to_string(), - }, - ] - ); - } - - #[test] - fn runtime_audio_backends_include_available_pipewire_and_jack() { - let interfaces = vec![AudioInterfaceInventory { - alias: "USB".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "alsa_hw_1_0".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "USB Audio".to_string(), - system_ref: Some("hw:1,0".to_string()), - }]; - - assert_eq!( - runtime_audio_backends(&interfaces, |command| matches!(command, "pw-cli" | "jackd")), - vec!["alsa", "jack", "pipewire"] - ); - } - - #[test] - fn runtime_audio_backends_keep_unknown_fallback() { - assert_eq!( - runtime_audio_backends(&[fallback_interface()], |_| false), - vec!["unknown"] - ); - } - - #[test] - fn runtime_audio_backends_drop_unknown_when_real_backend_available() { - assert_eq!( - runtime_audio_backends(&[fallback_interface()], |command| command == "pipewire"), - vec!["pipewire"] - ); - } - - #[test] - fn runtime_audio_backends_dedupe_and_sort() { - let interfaces = vec![ - AudioInterfaceInventory { - alias: "A".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "a".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "A".to_string(), - system_ref: Some("hw:1,0".to_string()), - }, - AudioInterfaceInventory { - alias: "B".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "b".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "B".to_string(), - system_ref: Some("hw:2,0".to_string()), - }, - ]; - - assert_eq!(runtime_audio_backends(&interfaces, |_| false), vec!["alsa"]); - } - - #[test] - fn parses_serial_from_sysfs_uevent() { - assert_eq!( - serial_from_uevent( - r#" -PRODUCT=1397/50/100 -SERIAL=x32-rack-usb-serial -TYPE=0/0/0 -"# - ), - Some("x32-rack-usb-serial".to_string()) - ); - } -} diff --git a/crates/recorder-agent/src/inventory/alsa.rs b/crates/recorder-agent/src/inventory/alsa.rs new file mode 100644 index 00000000..261a9500 --- /dev/null +++ b/crates/recorder-agent/src/inventory/alsa.rs @@ -0,0 +1,570 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::AgentConfig; + +use super::{AudioChannelInventory, AudioInterfaceInventory}; + +pub(crate) fn discover_audio_interfaces(config: &AgentConfig) -> Vec { + let mut alsa_interfaces = discover_arecord_interfaces(&config.inventory_arecord_command); + + if alsa_interfaces.is_empty() { + alsa_interfaces = discover_proc_asound_interfaces( + &config.inventory_proc_asound_pcm_path, + &config.inventory_arecord_command, + ); + } + + if alsa_interfaces.is_empty() { + vec![fallback_interface()] + } else { + alsa_interfaces + } +} + +fn discover_arecord_interfaces(arecord_command: &str) -> Vec { + let Ok(output) = Command::new(arecord_command).arg("-l").output() else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + let list_output = String::from_utf8_lossy(&output.stdout); + + parse_alsa_capture_devices(&list_output) + .into_iter() + .map(|device| { + let metadata = read_alsa_metadata(device.card, device.device, arecord_command); + let channel_count = metadata.channel_count.unwrap_or(2); + let sample_rates = if metadata.sample_rates.is_empty() { + vec![48_000] + } else { + metadata.sample_rates + }; + + AudioInterfaceInventory { + alias: device.system_name.clone(), + backend: "alsa".to_string(), + channel_count, + channels: channels(channel_count), + hardware_path: Some(alsa_hardware_path(device.card, device.device)), + sample_rates, + serial_number: alsa_serial_number(device.card), + id: alsa_interface_id(&device), + system_name: device.system_name, + system_ref: Some(device.system_ref), + } + }) + .collect() +} + +fn discover_proc_asound_interfaces( + path: &Path, + arecord_command: &str, +) -> Vec { + fs::read_to_string(path) + .map(|content| { + parse_proc_asound_pcm_devices(&content) + .into_iter() + .map(|device| { + let metadata = read_alsa_metadata(device.card, device.device, arecord_command); + let channel_count = metadata.channel_count.unwrap_or(2); + let sample_rates = if metadata.sample_rates.is_empty() { + vec![48_000] + } else { + metadata.sample_rates + }; + + AudioInterfaceInventory { + alias: device.system_name.clone(), + backend: "alsa".to_string(), + channel_count, + channels: channels(channel_count), + hardware_path: Some(alsa_hardware_path(device.card, device.device)), + id: format!("alsa_hw_{}_{}", device.card, device.device), + sample_rates, + serial_number: alsa_serial_number(device.card), + system_name: device.system_name, + system_ref: Some(device.system_ref), + } + }) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn fallback_interface() -> AudioInterfaceInventory { + AudioInterfaceInventory { + alias: "Default Capture Interface".to_string(), + backend: "unknown".to_string(), + channel_count: 2, + channels: channels(2), + hardware_path: None, + id: "iface_default_capture".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "Audio backend discovery pending".to_string(), + system_ref: None, + } +} + +fn channels(channel_count: u16) -> Vec { + (1..=channel_count) + .map(|index| AudioChannelInventory { + alias: format!("Input {index}"), + index, + }) + .collect() +} + +fn alsa_hardware_path(card: u16, device: u16) -> String { + canonical_path(format!("/sys/class/sound/card{card}/pcmC{card}D{device}c")) + .or_else(|| canonical_path(format!("/sys/class/sound/card{card}/device"))) + .unwrap_or_else(|| PathBuf::from(format!("/proc/asound/card{card}/pcm{device}c"))) + .to_string_lossy() + .to_string() +} + +fn alsa_interface_id(device: &AlsaCaptureDevice) -> String { + let Some(card_id) = alsa_card_id(&device.system_ref) else { + return format!("alsa_hw_{}_{}", device.card, device.device); + }; + let normalized = normalize_inventory_id_token(&card_id); + + if normalized.is_empty() { + format!("alsa_hw_{}_{}", device.card, device.device) + } else { + format!("alsa_card_{normalized}_dev_{}", device.device) + } +} + +fn alsa_card_id(system_ref: &str) -> Option { + system_ref + .strip_prefix("hw:")? + .split(',') + .find_map(|part| part.trim().strip_prefix("CARD=").map(str::to_string)) +} + +fn normalize_inventory_id_token(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn alsa_serial_number(card: u16) -> Option { + let device_path = canonical_path(format!("/sys/class/sound/card{card}/device"))?; + + sysfs_serial_number(&device_path) +} + +fn sysfs_serial_number(path: &Path) -> Option { + path.ancestors().find_map(|candidate| { + read_trimmed(candidate.join("serial")) + .or_else(|| serial_from_uevent(&read_trimmed(candidate.join("uevent"))?)) + }) +} + +fn canonical_path(path: impl AsRef) -> Option { + fs::canonicalize(path).ok() +} + +fn read_trimmed(path: impl AsRef) -> Option { + let value = fs::read_to_string(path).ok()?.trim().to_string(); + + if value.is_empty() { None } else { Some(value) } +} + +fn serial_from_uevent(value: &str) -> Option { + value.lines().find_map(|line| { + let serial = line.strip_prefix("SERIAL=")?.trim(); + + if serial.is_empty() { + None + } else { + Some(serial.to_string()) + } + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct AlsaCaptureDevice { + card: u16, + device: u16, + system_ref: String, + system_name: String, +} + +fn parse_alsa_capture_devices(input: &str) -> Vec { + input + .lines() + .filter_map(parse_alsa_capture_device) + .collect() +} + +fn parse_proc_asound_pcm_devices(input: &str) -> Vec { + input + .lines() + .filter_map(parse_proc_asound_pcm_device) + .collect() +} + +fn parse_alsa_capture_device(line: &str) -> Option { + let after_card = line.trim().strip_prefix("card ")?; + let (card_text, after_card_number) = after_card.split_once(':')?; + let card = card_text.trim().parse::().ok()?; + let (card_label_text, after_device_marker) = after_card_number.split_once(", device ")?; + let (device_text, device_label_text) = after_device_marker.split_once(':')?; + let device = device_text.trim().parse::().ok()?; + let card_id = card_label_text.split_whitespace().next()?; + let card_label = bracket_label(card_label_text).unwrap_or_else(|| card_label_text.trim()); + let device_label = bracket_label(device_label_text).unwrap_or_else(|| device_label_text.trim()); + let system_name = if card_label.eq_ignore_ascii_case(device_label) { + device_label.to_string() + } else { + format!("{card_label} {device_label}") + }; + + Some(AlsaCaptureDevice { + card, + device, + system_ref: format!("hw:CARD={card_id},DEV={device}"), + system_name, + }) +} + +fn parse_proc_asound_pcm_device(line: &str) -> Option { + if !line.contains(": capture") { + return None; + } + + let (address, rest) = line.split_once(':')?; + let (card_text, device_text) = address.trim().split_once('-')?; + let card = card_text.trim().parse::().ok()?; + let device = device_text.trim().parse::().ok()?; + let system_name = rest + .split(':') + .map(str::trim) + .find(|part| { + !part.is_empty() && !part.starts_with("playback") && !part.starts_with("capture") + })? + .to_string(); + + Some(AlsaCaptureDevice { + card, + device, + system_ref: format!("hw:{card},{device}"), + system_name, + }) +} + +fn bracket_label(value: &str) -> Option<&str> { + let (_, after_open) = value.split_once('[')?; + let (label, _) = after_open.split_once(']')?; + Some(label.trim()) +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct AlsaStreamMetadata { + channel_count: Option, + sample_rates: Vec, +} + +fn read_alsa_stream_metadata(card: u16) -> AlsaStreamMetadata { + fs::read_to_string(format!("/proc/asound/card{card}/stream0")) + .map(|content| parse_alsa_stream_metadata(&content)) + .unwrap_or_default() +} + +fn read_alsa_metadata(card: u16, device: u16, arecord_command: &str) -> AlsaStreamMetadata { + let metadata = read_alsa_stream_metadata(card); + + if metadata.is_complete() { + metadata + } else { + metadata.with_fallback(read_alsa_hw_params_metadata(card, device, arecord_command)) + } +} + +fn read_alsa_hw_params_metadata( + card: u16, + device: u16, + arecord_command: &str, +) -> AlsaStreamMetadata { + let Ok(output) = Command::new(arecord_command) + .arg("-D") + .arg(format!("hw:{card},{device}")) + .arg("--dump-hw-params") + .arg("-f") + .arg("S16_LE") + .arg("-r") + .arg("48000") + .arg("-c") + .arg("2") + .arg("-d") + .arg("1") + .arg("/dev/null") + .output() + else { + return AlsaStreamMetadata::default(); + }; + + if !output.status.success() { + return AlsaStreamMetadata::default(); + } + + let mut content = String::from_utf8_lossy(&output.stdout).to_string(); + content.push_str(&String::from_utf8_lossy(&output.stderr)); + + parse_alsa_hw_params_metadata(&content) +} + +fn parse_alsa_stream_metadata(input: &str) -> AlsaStreamMetadata { + let mut metadata = AlsaStreamMetadata::default(); + let mut in_capture = false; + + for line in input.lines() { + let trimmed = line.trim(); + + if trimmed == "Capture:" { + in_capture = true; + continue; + } + + if trimmed.ends_with(':') && trimmed != "Capture:" { + in_capture = false; + } + + if !in_capture { + continue; + } + + if let Some(value) = trimmed.strip_prefix("Channels:") { + metadata.channel_count = parse_numbers::(value).into_iter().max(); + } else if let Some(value) = trimmed.strip_prefix("Rates:") { + metadata.sample_rates = parse_numbers(value); + } + } + + metadata.sample_rates.sort_unstable(); + metadata.sample_rates.dedup(); + metadata +} + +fn parse_alsa_hw_params_metadata(input: &str) -> AlsaStreamMetadata { + let mut metadata = AlsaStreamMetadata::default(); + + for line in input.lines() { + let trimmed = line.trim(); + + if let Some(value) = trimmed.strip_prefix("CHANNELS:") { + metadata.channel_count = parse_numbers::(value).into_iter().max(); + } else if let Some(value) = trimmed.strip_prefix("RATE:") { + metadata.sample_rates = parse_numbers(value); + } + } + + metadata.sample_rates.sort_unstable(); + metadata.sample_rates.dedup(); + metadata +} + +impl AlsaStreamMetadata { + fn is_complete(&self) -> bool { + self.channel_count.is_some() && !self.sample_rates.is_empty() + } + + fn with_fallback(mut self, fallback: Self) -> Self { + if self.channel_count.is_none() { + self.channel_count = fallback.channel_count; + } + + if self.sample_rates.is_empty() { + self.sample_rates = fallback.sample_rates; + } + + self + } +} + +fn parse_numbers(value: &str) -> Vec +where + T: std::str::FromStr, +{ + value + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_arecord_capture_devices() { + let devices = parse_alsa_capture_devices( + r#" +**** List of CAPTURE Hardware Devices **** +card 2: XUSB [X-USB], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +"#, + ); + + assert_eq!( + devices, + vec![AlsaCaptureDevice { + card: 2, + device: 0, + system_ref: "hw:CARD=XUSB,DEV=0".to_string(), + system_name: "X-USB USB Audio".to_string(), + }] + ); + } + + #[test] + fn stable_alsa_interface_id_uses_card_name_when_available() { + let device = AlsaCaptureDevice { + card: 7, + device: 0, + system_ref: "hw:CARD=SMOKE,DEV=0".to_string(), + system_name: "Smoke Audio Capture".to_string(), + }; + + assert_eq!(alsa_interface_id(&device), "alsa_card_smoke_dev_0"); + } + + #[test] + fn parses_capture_stream_metadata() { + let metadata = parse_alsa_stream_metadata( + r#" +Playback: + Channels: 2 + Rates: 48000 +Capture: + Channels: 32 + Rates: 44100, 48000 +"#, + ); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![44_100, 48_000], + } + ); + } + + #[test] + fn parses_arecord_hw_params_metadata() { + let metadata = parse_alsa_hw_params_metadata( + r#" +HW Params of device "hw:1,0": +-------------------- +ACCESS: MMAP_INTERLEAVED RW_INTERLEAVED +FORMAT: S16_LE S24_LE S32_LE +CHANNELS: [1 32] +RATE: [44100 96000] +PERIOD_SIZE: [32 8192] +"#, + ); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![44_100, 96_000], + } + ); + } + + #[test] + fn keeps_proc_stream_metadata_before_hw_params_fallback() { + let metadata = AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![48_000], + } + .with_fallback(AlsaStreamMetadata { + channel_count: Some(2), + sample_rates: vec![44_100], + }); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![48_000], + } + ); + } + + #[test] + fn fills_missing_stream_metadata_from_hw_params_fallback() { + let metadata = AlsaStreamMetadata { + channel_count: None, + sample_rates: Vec::new(), + } + .with_fallback(AlsaStreamMetadata { + channel_count: Some(8), + sample_rates: vec![44_100, 48_000], + }); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(8), + sample_rates: vec![44_100, 48_000], + } + ); + } + + #[test] + fn parses_proc_asound_pcm_capture_devices() { + let devices = parse_proc_asound_pcm_devices( + r#" +00-00: ALC256 Analog : ALC256 Analog : playback 1 : capture 1 +00-03: HDMI 0 : HDMI 0 : playback 1 +01-00: USB Audio : USB Audio : capture 1 +"#, + ); + + assert_eq!( + devices, + vec![ + AlsaCaptureDevice { + card: 0, + device: 0, + system_ref: "hw:0,0".to_string(), + system_name: "ALC256 Analog".to_string(), + }, + AlsaCaptureDevice { + card: 1, + device: 0, + system_ref: "hw:1,0".to_string(), + system_name: "USB Audio".to_string(), + }, + ] + ); + } + + #[test] + fn parses_serial_from_sysfs_uevent() { + assert_eq!( + serial_from_uevent( + r#" +PRODUCT=1397/50/100 +SERIAL=x32-rack-usb-serial +TYPE=0/0/0 +"# + ), + Some("x32-rack-usb-serial".to_string()) + ); + } +} diff --git a/crates/recorder-agent/src/inventory/net.rs b/crates/recorder-agent/src/inventory/net.rs new file mode 100644 index 00000000..54b6caf8 --- /dev/null +++ b/crates/recorder-agent/src/inventory/net.rs @@ -0,0 +1,60 @@ +use std::process::Command; + +// The controller caps a heartbeat's `ipAddresses` at 16 (nodeHeartbeatSchema) +// and truncates an over-cap list rather than reject it. A well-behaved agent +// should never emit more than the cap in the first place, so bound the list +// here too (a multi-homed host — IPv6 SLAAC/privacy + Docker/libvirt/VLAN +// bridges — can exceed 16). See audit R7-IP-AGENT-CAP / R7-IPCAP. +const MAX_IP_ADDRESSES: usize = 16; + +pub(crate) fn collect_ip_addresses() -> Vec { + let Ok(output) = Command::new("hostname").arg("-I").output() else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + parse_ip_addresses(&String::from_utf8_lossy(&output.stdout)) +} + +// Pure parser for `hostname -I` output: whitespace-split, bounded to the +// documented cap so the agent never sends a payload the controller would have +// to truncate. +fn parse_ip_addresses(stdout: &str) -> Vec { + stdout + .split_whitespace() + .take(MAX_IP_ADDRESSES) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ip_addresses_and_caps_at_the_documented_limit() { + // A normal multi-address host parses to a trimmed list. + assert_eq!( + parse_ip_addresses("192.168.1.10 10.0.0.5 \n"), + vec!["192.168.1.10".to_string(), "10.0.0.5".to_string()], + ); + + // A host with more than the cap (e.g. IPv6 SLAAC/privacy + bridges) is + // bounded to MAX_IP_ADDRESSES so the controller never has to truncate. + let many = (0..40) + .map(|n| format!("10.0.0.{n}")) + .collect::>() + .join(" "); + let parsed = parse_ip_addresses(&many); + + assert_eq!(parsed.len(), MAX_IP_ADDRESSES); + assert_eq!(parsed.first().map(String::as_str), Some("10.0.0.0")); + assert_eq!(parsed.last().map(String::as_str), Some("10.0.0.15")); + + // Empty output yields no addresses. + assert!(parse_ip_addresses(" \n").is_empty()); + } +} diff --git a/crates/recorder-agent/src/inventory/runtime.rs b/crates/recorder-agent/src/inventory/runtime.rs new file mode 100644 index 00000000..d3e338b0 --- /dev/null +++ b/crates/recorder-agent/src/inventory/runtime.rs @@ -0,0 +1,190 @@ +use std::env; +use std::fs; +use std::process::Command; + +use super::{AudioInterfaceInventory, NodeRuntime}; + +pub(crate) fn runtime_details(interfaces: &[AudioInterfaceInventory]) -> NodeRuntime { + NodeRuntime { + architecture: env::consts::ARCH.to_string(), + audio_backends: runtime_audio_backends(interfaces, command_available), + kernel_release: command_stdout("uname", &["-r"]), + os_name: linux_pretty_name().or_else(|| command_stdout("uname", &["-s"])), + uptime_seconds: linux_uptime_seconds(), + } +} + +fn runtime_audio_backends( + interfaces: &[AudioInterfaceInventory], + mut is_command_available: impl FnMut(&str) -> bool, +) -> Vec { + let mut audio_backends = interfaces + .iter() + .map(|audio_interface| audio_interface.backend.clone()) + .collect::>(); + + if is_command_available("pw-cli") || is_command_available("pipewire") { + audio_backends.push("pipewire".to_string()); + } + + if is_command_available("jackd") || is_command_available("jack_control") { + audio_backends.push("jack".to_string()); + } + + if audio_backends.iter().any(|backend| backend != "unknown") { + audio_backends.retain(|backend| backend != "unknown"); + } + + audio_backends.sort(); + audio_backends.dedup(); + audio_backends +} + +fn command_available(command: &str) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + let extensions = command_extensions(); + + env::split_paths(&paths).any(|path| { + extensions + .iter() + .map(|extension| path.join(format!("{command}{extension}"))) + .any(|candidate| candidate.is_file()) + }) +} + +fn command_extensions() -> Vec { + if cfg!(windows) { + env::var_os("PATHEXT") + .map(|value| { + let mut extensions = vec!["".to_string()]; + extensions.extend( + value + .to_string_lossy() + .split(';') + .map(str::trim) + .filter(|extension| !extension.is_empty()) + .map(str::to_string), + ); + + extensions + }) + .filter(|extensions| extensions.len() > 1) + .unwrap_or_else(|| { + vec![ + "".to_string(), + ".exe".to_string(), + ".cmd".to_string(), + ".bat".to_string(), + ] + }) + } else { + vec!["".to_string()] + } +} + +fn command_stdout(command: &str, args: &[&str]) -> Option { + let output = Command::new(command).args(args).output().ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if value.is_empty() { None } else { Some(value) } +} + +fn linux_pretty_name() -> Option { + let content = fs::read_to_string("/etc/os-release").ok()?; + + content.lines().find_map(|line| { + let value = line.strip_prefix("PRETTY_NAME=")?; + + Some(value.trim_matches('"').to_string()) + }) +} + +fn linux_uptime_seconds() -> Option { + let content = fs::read_to_string("/proc/uptime").ok()?; + let first = content.split_whitespace().next()?; + let seconds = first.split('.').next()?.parse::().ok()?; + + Some(seconds) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::alsa::fallback_interface; + + #[test] + fn runtime_audio_backends_include_available_pipewire_and_jack() { + let interfaces = vec![AudioInterfaceInventory { + alias: "USB".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "alsa_hw_1_0".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "USB Audio".to_string(), + system_ref: Some("hw:1,0".to_string()), + }]; + + assert_eq!( + runtime_audio_backends(&interfaces, |command| matches!(command, "pw-cli" | "jackd")), + vec!["alsa", "jack", "pipewire"] + ); + } + + #[test] + fn runtime_audio_backends_keep_unknown_fallback() { + assert_eq!( + runtime_audio_backends(&[fallback_interface()], |_| false), + vec!["unknown"] + ); + } + + #[test] + fn runtime_audio_backends_drop_unknown_when_real_backend_available() { + assert_eq!( + runtime_audio_backends(&[fallback_interface()], |command| command == "pipewire"), + vec!["pipewire"] + ); + } + + #[test] + fn runtime_audio_backends_dedupe_and_sort() { + let interfaces = vec![ + AudioInterfaceInventory { + alias: "A".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "a".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "A".to_string(), + system_ref: Some("hw:1,0".to_string()), + }, + AudioInterfaceInventory { + alias: "B".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "b".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "B".to_string(), + system_ref: Some("hw:2,0".to_string()), + }, + ]; + + assert_eq!(runtime_audio_backends(&interfaces, |_| false), vec!["alsa"]); + } +} From ae5092a070b9bcec6576b5bc5b65c8db70ce82e6 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 21:11:03 +0500 Subject: [PATCH 5/9] Split six oversized API test files into harness + themed specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings-routes, schedule-routes, recording-routes, agent-routes, recording-job-export, and upload-runner tests (all 960–999 LOC) each split into a co-located non-test harness module (env-before-import ordering + shared helpers + test.after cleanup preserved) plus focused sibling *.test.ts files discovered by the test/**/*.test.ts glob. Every test case moved verbatim; the full suite still reports 662 tests / 643 pass / 19 skipped with 0 failures. Co-Authored-By: Claude Opus 4.8 --- apps/api/test/agent-routes-harness.ts | 345 ++++++++ .../agent-routes-recording-lifecycle.test.ts | 365 ++++++++ apps/api/test/agent-routes.test.ts | 679 +-------------- apps/api/test/recording-job-export-harness.ts | 272 ++++++ .../recording-job-export-lifecycle.test.ts | 204 +++++ .../test/recording-job-export-read.test.ts | 352 ++++++++ apps/api/test/recording-job-export.test.ts | 795 +----------------- apps/api/test/recording-routes-delete.test.ts | 232 +++++ apps/api/test/recording-routes-harness.ts | 308 +++++++ .../test/recording-routes-metadata.test.ts | 167 ++++ apps/api/test/recording-routes.test.ts | 686 +-------------- apps/api/test/schedule-routes-actions.test.ts | 152 ++++ apps/api/test/schedule-routes-harness.ts | 239 ++++++ .../test/schedule-routes-lifecycle.test.ts | 338 ++++++++ apps/api/test/schedule-routes.test.ts | 701 +-------------- apps/api/test/settings-routes-harness.ts | 207 +++++ .../test/settings-routes-mutations.test.ts | 397 +++++++++ apps/api/test/settings-routes.test.ts | 576 +------------ apps/api/test/upload-runner-harness.ts | 191 +++++ apps/api/test/upload-runner-reconcile.test.ts | 209 +++++ apps/api/test/upload-runner-routes.test.ts | 254 ++++++ apps/api/test/upload-runner.test.ts | 628 +------------- 22 files changed, 4315 insertions(+), 3982 deletions(-) create mode 100644 apps/api/test/agent-routes-harness.ts create mode 100644 apps/api/test/agent-routes-recording-lifecycle.test.ts create mode 100644 apps/api/test/recording-job-export-harness.ts create mode 100644 apps/api/test/recording-job-export-lifecycle.test.ts create mode 100644 apps/api/test/recording-job-export-read.test.ts create mode 100644 apps/api/test/recording-routes-delete.test.ts create mode 100644 apps/api/test/recording-routes-harness.ts create mode 100644 apps/api/test/recording-routes-metadata.test.ts create mode 100644 apps/api/test/schedule-routes-actions.test.ts create mode 100644 apps/api/test/schedule-routes-harness.ts create mode 100644 apps/api/test/schedule-routes-lifecycle.test.ts create mode 100644 apps/api/test/settings-routes-harness.ts create mode 100644 apps/api/test/settings-routes-mutations.test.ts create mode 100644 apps/api/test/upload-runner-harness.ts create mode 100644 apps/api/test/upload-runner-reconcile.test.ts create mode 100644 apps/api/test/upload-runner-routes.test.ts diff --git a/apps/api/test/agent-routes-harness.ts b/apps/api/test/agent-routes-harness.ts new file mode 100644 index 00000000..d7b77566 --- /dev/null +++ b/apps/api/test/agent-routes-harness.ts @@ -0,0 +1,345 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { + AuditEvent, + CurrentUser, + MeterFrame, + Permission, + RecorderNode, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import type { AuthResult } from "../src/auth-service.js"; +import type { RecordAuditEvent, RequirePermission } from "../src/http-types.js"; +import type { MeterFrameStore } from "../src/meter-store.js"; +import type { NodeHeartbeatInput, NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +// Shared harness for the agent route tests. Extracted from agent-routes.test.ts +// to keep each test file under the 1000-LOC guard. +// +// The store modules read their RAKKR_*_STORE_PATH env vars at import time, so +// the env setup below MUST run before the dynamic imports. + +const agentRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(agentRoot, "jobs.json"); +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(agentRoot, "cache"); +process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(agentRoot, "retention-policies.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(agentRoot, "upload-policies.json"); +process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(agentRoot, "upload-queue.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { registerAgentRoutes } = await import("../src/agent-routes.js"); +const { createHealthEventStore } = await import("../src/health-store.js"); +const { createRecordingJob } = await import("../src/recording-jobs.js"); +const { createRetentionPolicy } = await import("../src/retention-policies.js"); +const { registerRecordingRoutes } = await import("../src/recording-routes.js"); +const { createUploadPolicy } = await import("../src/upload-policies.js"); + +export { + createAuditStore, + createHealthEventStore, + createRecordingJob, + createRetentionPolicy, + createUploadPolicy, + registerAgentRoutes, + registerRecordingRoutes, +}; + +test.after(async () => { + await rm(agentRoot, { force: true, recursive: true }); +}); + +export function memoryNodeStore(nodes: RecorderNode[] = [node()]): NodeStore { + return { + async authenticateCredential(token) { + return token === "node-token" + ? { + credentialId: "cred_agent_test", + nodeId: nodes[0]?.id ?? "node_agent_test", + tokenPrefix: "node-token", + } + : undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodes.find((candidate) => candidate.id === nodeId); + }, + async heartbeat(nodeId: string, input: NodeHeartbeatInput) { + const index = nodes.findIndex((candidate) => candidate.id === nodeId); + + if (index < 0) { + return undefined; + } + + nodes[index] = { + ...nodes[index], + agentVersion: input.agentVersion, + hostname: input.hostname, + ipAddresses: input.ipAddresses, + lastSeenAt: new Date().toISOString(), + runtime: input.runtime, + status: input.status, + }; + + return nodes[index]; + }, + async list() { + return nodes; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + }; +} + +export function requirePermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function memoryMeterFrameStore(): MeterFrameStore { + const frames: MeterFrame[] = []; + + return { + async history(nodeId, limit = frames.length) { + return frames.filter((frame) => frame.nodeId === nodeId).slice(0, limit); + }, + async latest() { + return frames[0]; + }, + async save(frame) { + frames.unshift(frame); + + return { + frame, + receivedAt: new Date().toISOString(), + }; + }, + }; +} + +export function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { + return { + async create(recording) { + recordings.unshift(recording); + }, + async delete(recordingId) { + const index = recordings.findIndex((candidate) => candidate.id === recordingId); + + if (index < 0) { + return undefined; + } + + const [deleted] = recordings.splice(index, 1); + + return deleted; + }, + async find(recordingId) { + return recordings.find((candidate) => candidate.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + + if (index >= 0) { + recordings[index] = recording; + } + }, + async transition(recording, allowedFrom) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + const current = recordings[index]; + + if (!current || !allowedFrom.includes(current.status)) { + return undefined; + } + + recordings[index] = recording; + + return recording; + }, + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: input.actor ?? { + id: "anonymous", + name: "Anonymous", + roles: [], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { + return { + async assignChannelMapTemplate() { + throw new Error("not implemented"); + }, + async createChannelMapTemplate() { + throw new Error("not implemented"); + }, + async findChannelMapTemplate() { + return undefined; + }, + async findRecordingProfile(profileId) { + return profiles.find((profile) => profile.id === profileId); + }, + async findWatchdogPolicy() { + return undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listChannelMapTemplates() { + return []; + }, + async listRecordingProfiles() { + return profiles; + }, + async listWatchdogPolicies() { + return []; + }, + async rollbackChannelMapAssignment() { + return undefined; + }, + async updateChannelMapTemplate() { + return undefined; + }, + async updateRecordingProfile() { + return undefined; + }, + async updateWatchdogPolicy() { + return undefined; + }, + }; +} + +export function auth(): AuthResult { + return { user: user() }; +} + +export function user(): CurrentUser { + return { + email: "agent-route@example.com", + groups: [], + id: "user_agent_route", + name: "Agent Route User", + permissions: [ + "recording:create", + "recording:download", + "recording:playback", + "recording:read", + ] satisfies Permission[], + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function node(): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Agent Route Node", + hostname: "agent-route-node", + id: "node_agent_test", + interfaces: [], + ipAddresses: ["127.0.0.1"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Test Room", + site: "Test Site", + }, + status: "recording", + tags: [], + }; +} + +export function recording(): RecordingSummary { + return { + cached: false, + durationSeconds: 900, + folder: "Meetings/2026", + healthStatus: "unknown", + id: "rec_agent_failure", + name: "Agent Failure Test", + nodeId: "node_agent_test", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "schedule", + status: "recording", + tags: ["voice"], + }; +} + +export function flacProfile(): RecordingProfile { + return { + bitrateKbps: 256, + channelMode: "stereo", + codec: "flac", + id: "voice-flac", + name: "Voice FLAC", + silenceDetectionEnabled: false, + silenceSkipEnabled: false, + vbr: false, + }; +} + +export function wavFile(samples: number[]) { + const dataSize = samples.length * 2; + const buffer = Buffer.alloc(44 + dataSize); + + buffer.write("RIFF", 0, "ascii"); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write("WAVE", 8, "ascii"); + buffer.write("fmt ", 12, "ascii"); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(48_000, 24); + buffer.writeUInt32LE(96_000, 28); + buffer.writeUInt16LE(2, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36, "ascii"); + buffer.writeUInt32LE(dataSize, 40); + + samples.forEach((sample, index) => buffer.writeInt16LE(sample, 44 + index * 2)); + + return buffer; +} diff --git a/apps/api/test/agent-routes-recording-lifecycle.test.ts b/apps/api/test/agent-routes-recording-lifecycle.test.ts new file mode 100644 index 00000000..78f003c7 --- /dev/null +++ b/apps/api/test/agent-routes-recording-lifecycle.test.ts @@ -0,0 +1,365 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + auth, + createAuditStore, + createHealthEventStore, + createUploadPolicy, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + memorySettingsStore, + node, + recordAuditEvent, + registerAgentRoutes, + registerRecordingRoutes, + requirePermission, + user, + wavFile, +} from "./agent-routes-harness.js"; + +test("ad hoc recording completes through agent cache attach and exposes cached media", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Lifecycle Recorder", + id: `node_lifecycle_${randomUUID()}`, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + const policy = await createUploadPolicy({ + enabled: true, + id: `upload-policy-lifecycle-${randomUUID()}`, + maxAttempts: 2, + name: "Lifecycle Auto Stub", + trigger: "on_recording_cached", + }); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const started = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + name: "Lifecycle Recording", + nodeId: lifecycleNode.id, + tags: ["voice", "lifecycle"], + uploadPolicyIds: [policy.id], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; + const next = await app.request(`/api/v1/nodes/${lifecycleNode.id}/recording-jobs/next`, { + headers: { authorization: "Bearer node-token" }, + }); + const nextBody = (await next.json()) as { data: RecordingJob }; + const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const heartbeat = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/heartbeat`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const wavBytes = wavFile([0, 12_000, -24_000, 6000]); + const attached = await app.request(`/api/v1/recordings/${startedBody.data.id}/cache-file`, { + body: wavBytes, + headers: { + authorization: "Bearer node-token", + "content-type": "audio/wav", + "x-rakkr-duration-seconds": "2", + "x-rakkr-file-name": "lifecycle.wav", + "x-rakkr-recording-job-id": startedBody.job.id, + }, + method: "PUT", + }); + const attachedBody = (await attached.json()) as { + data: { + recording: RecordingSummary; + uploadQueueItem?: { recordingId: string; uploadPolicyId?: string }; + }; + }; + const completedJob = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}`, { + headers: { authorization: "Bearer node-token" }, + }); + const completedJobBody = (await completedJob.json()) as { data: RecordingJob }; + const playback = await app.request(`/api/v1/recordings/${startedBody.data.id}/playback`, { + method: "POST", + }); + const download = await app.request(`/api/v1/recordings/${startedBody.data.id}/download`, { + method: "POST", + }); + const stream = await app.request(`/api/v1/recordings/${startedBody.data.id}/stream`); + const file = await app.request(`/api/v1/recordings/${startedBody.data.id}/file`); + const cached = await recordingStore.find(startedBody.data.id); + const [cacheAudit] = await auditStore.list({ action: "recordings.cache_file.attach.succeeded" }); + const [autoQueueAudit] = await auditStore.list({ + action: "recordings.upload_queue.auto_enqueue.succeeded", + }); + + assert.equal(started.status, 202); + assert.equal(next.status, 200); + assert.equal(nextBody.data.id, startedBody.job.id); + assert.equal(claimed.status, 200); + assert.equal(heartbeat.status, 200); + assert.equal(attached.status, 201); + assert.equal(attachedBody.data.recording.cached, true); + assert.equal(attachedBody.data.recording.status, "cached"); + assert.equal(attachedBody.data.recording.healthStatus, "healthy"); + assert.equal(attachedBody.data.recording.cachePath, `ad-hoc/${startedBody.data.id}.wav`); + assert.equal(attachedBody.data.recording.durationSeconds, 2); + assert.equal(attachedBody.data.recording.waveformPreview?.peaks.length, 4); + assert.equal(attachedBody.data.uploadQueueItem?.recordingId, startedBody.data.id); + assert.equal(attachedBody.data.uploadQueueItem?.uploadPolicyId, policy.id); + assert.equal(completedJob.status, 200); + assert.equal(completedJobBody.data.status, "completed"); + assert.equal(cached?.status, "cached"); + assert.equal(cached?.healthStatus, "healthy"); + assert.equal(playback.status, 202); + assert.equal(download.status, 202); + assert.equal(stream.status, 200); + assert.equal(stream.headers.get("content-type"), "audio/wav"); + assert.equal((await stream.arrayBuffer()).byteLength, wavBytes.byteLength); + assert.equal(file.status, 200); + assert.equal( + file.headers.get("content-disposition"), + 'attachment; filename="Lifecycle Recording.wav"', + ); + assert.equal(cacheAudit?.details.jobStatus, "completed"); + assert.equal(autoQueueAudit?.details.uploadPolicyId, policy.id); +}); + +test("claim-next lets one node claim multiple queued recordings independently", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Concurrent Lifecycle Recorder", + id: `node_concurrent_lifecycle_${randomUUID()}`, + interfaces: [ + { + alias: "Concurrent X32", + backend: "alsa" as const, + channelCount: 4, + channels: [ + { alias: "Ch 1", index: 1 }, + { alias: "Ch 2", index: 2 }, + { alias: "Ch 3", index: 3 }, + { alias: "Ch 4", index: 4 }, + ], + id: "iface_concurrent_x32", + sampleRates: [48_000], + systemName: "Concurrent X32", + systemRef: "hw:CARD=X32,DEV=0", + }, + ], + recordingCapacity: { maxConcurrentRecordings: 2 }, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const firstStarted = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + captureChannelSelection: [1, 2], + captureInterfaceId: "iface_concurrent_x32", + channelMode: "stereo", + name: "Concurrent Recording A", + nodeId: lifecycleNode.id, + tags: ["voice", "concurrent"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const secondStarted = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + captureChannelSelection: [3, 4], + captureInterfaceId: "iface_concurrent_x32", + channelMode: "stereo", + name: "Concurrent Recording B", + nodeId: lifecycleNode.id, + tags: ["voice", "concurrent"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const firstStartedBody = (await firstStarted.json()) as { + data: RecordingSummary; + job: RecordingJob; + }; + const secondStartedBody = (await secondStarted.json()) as { + data: RecordingSummary; + job: RecordingJob; + }; + const firstClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const secondClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const emptyClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const firstClaimBody = (await firstClaim.json()) as { data: RecordingJob }; + const secondClaimBody = (await secondClaim.json()) as { data: RecordingJob }; + const claimedJobIds = new Set([firstClaimBody.data.id, secondClaimBody.data.id]); + const expectedJobIds = new Set([firstStartedBody.job.id, secondStartedBody.job.id]); + const firstRecording = await recordingStore.find(firstStartedBody.data.id); + const secondRecording = await recordingStore.find(secondStartedBody.data.id); + const claimAudits = ( + await auditStore.list({ + action: "recording_jobs.claim_next.succeeded", + }) + ).filter((event) => event.target.type === "recording_job"); + assert.equal(firstStarted.status, 202); + assert.equal(secondStarted.status, 202); + assert.equal(firstClaim.status, 200); + assert.equal(secondClaim.status, 200); + assert.equal(emptyClaim.status, 204); + assert.deepEqual(claimedJobIds, expectedJobIds); + assert.equal(firstClaimBody.data.status, "running"); + assert.equal(secondClaimBody.data.status, "running"); + assert.equal(firstRecording?.status, "recording"); + assert.equal(secondRecording?.status, "recording"); + assert.equal(claimAudits.length, 2); +}); +test("controller stop request survives agent cancellation as completed recording", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Stop Lifecycle Recorder", + id: `node_stop_lifecycle_${randomUUID()}`, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const started = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + name: "Stop Lifecycle Recording", + nodeId: lifecycleNode.id, + tags: ["voice", "stop-lifecycle"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; + const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const stopped = await app.request(`/api/v1/recordings/${startedBody.data.id}/stop`, { + method: "POST", + }); + const stoppedBody = (await stopped.json()) as { data: RecordingSummary }; + const cancelled = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/cancelled`, { + headers: { + authorization: "Bearer node-token", + "x-rakkr-reason": "controller_stop_requested", + }, + method: "POST", + }); + const cancelledBody = (await cancelled.json()) as { data: RecordingJob }; + const updated = await recordingStore.find(startedBody.data.id); + const healthEvents = await healthEventStore.list({ recordingId: startedBody.data.id }); + const [stopAudit] = await auditStore.list({ action: "recordings.stop.succeeded" }); + const [cancelAudit] = await auditStore.list({ + action: "recording_jobs.cancelled.succeeded", + }); + + assert.equal(started.status, 202); + assert.equal(claimed.status, 200); + assert.equal(stopped.status, 200); + assert.equal(stoppedBody.data.status, "completed"); + assert.equal(stopAudit?.details.jobStatus, "stop_requested"); + assert.equal(cancelled.status, 200); + assert.equal(cancelledBody.data.status, "cancelled"); + assert.equal(updated?.status, "completed"); + assert.equal(updated?.healthStatus, "healthy"); + assert.equal(healthEvents.length, 0); + assert.equal(cancelAudit?.details.reason, "controller_stop_requested"); + assert.equal(cancelAudit?.details.recordingStatus, "completed"); +}); diff --git a/apps/api/test/agent-routes.test.ts b/apps/api/test/agent-routes.test.ts index 69ee5d89..3e97cfc2 100644 --- a/apps/api/test/agent-routes.test.ts +++ b/apps/api/test/agent-routes.test.ts @@ -1,44 +1,23 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AuditEvent, - CurrentUser, - MeterFrame, - Permission, - RecorderNode, - RecordingJob, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import type { AuthResult } from "../src/auth-service.js"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import type { MeterFrameStore } from "../src/meter-store.js"; -import type { NodeHeartbeatInput, NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; +import type { AppBindings } from "../src/http-types.js"; import type { SettingsStore } from "../src/settings-store.js"; -const agentRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(agentRoot, "jobs.json"); -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(agentRoot, "cache"); -process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(agentRoot, "retention-policies.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(agentRoot, "upload-policies.json"); -process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(agentRoot, "upload-queue.json"); -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerAgentRoutes } = await import("../src/agent-routes.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { createRecordingJob } = await import("../src/recording-jobs.js"); -const { createRetentionPolicy } = await import("../src/retention-policies.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -test.after(async () => { - await rm(agentRoot, { force: true, recursive: true }); -}); +import { + createAuditStore, + createHealthEventStore, + createRecordingJob, + createRetentionPolicy, + flacProfile, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + node, + recordAuditEvent, + recording, + registerAgentRoutes, +} from "./agent-routes-harness.js"; test("agent failed job marks recording metadata failed", async () => { const app = new Hono(); @@ -353,631 +332,3 @@ test("recording job carries recorder-cache retention policy", async () => { policyId: policy.id, }); }); - -test("ad hoc recording completes through agent cache attach and exposes cached media", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Lifecycle Recorder", - id: `node_lifecycle_${randomUUID()}`, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - const policy = await createUploadPolicy({ - enabled: true, - id: `upload-policy-lifecycle-${randomUUID()}`, - maxAttempts: 2, - name: "Lifecycle Auto Stub", - trigger: "on_recording_cached", - }); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const started = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - name: "Lifecycle Recording", - nodeId: lifecycleNode.id, - tags: ["voice", "lifecycle"], - uploadPolicyIds: [policy.id], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; - const next = await app.request(`/api/v1/nodes/${lifecycleNode.id}/recording-jobs/next`, { - headers: { authorization: "Bearer node-token" }, - }); - const nextBody = (await next.json()) as { data: RecordingJob }; - const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const heartbeat = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/heartbeat`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const wavBytes = wavFile([0, 12_000, -24_000, 6000]); - const attached = await app.request(`/api/v1/recordings/${startedBody.data.id}/cache-file`, { - body: wavBytes, - headers: { - authorization: "Bearer node-token", - "content-type": "audio/wav", - "x-rakkr-duration-seconds": "2", - "x-rakkr-file-name": "lifecycle.wav", - "x-rakkr-recording-job-id": startedBody.job.id, - }, - method: "PUT", - }); - const attachedBody = (await attached.json()) as { - data: { - recording: RecordingSummary; - uploadQueueItem?: { recordingId: string; uploadPolicyId?: string }; - }; - }; - const completedJob = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}`, { - headers: { authorization: "Bearer node-token" }, - }); - const completedJobBody = (await completedJob.json()) as { data: RecordingJob }; - const playback = await app.request(`/api/v1/recordings/${startedBody.data.id}/playback`, { - method: "POST", - }); - const download = await app.request(`/api/v1/recordings/${startedBody.data.id}/download`, { - method: "POST", - }); - const stream = await app.request(`/api/v1/recordings/${startedBody.data.id}/stream`); - const file = await app.request(`/api/v1/recordings/${startedBody.data.id}/file`); - const cached = await recordingStore.find(startedBody.data.id); - const [cacheAudit] = await auditStore.list({ action: "recordings.cache_file.attach.succeeded" }); - const [autoQueueAudit] = await auditStore.list({ - action: "recordings.upload_queue.auto_enqueue.succeeded", - }); - - assert.equal(started.status, 202); - assert.equal(next.status, 200); - assert.equal(nextBody.data.id, startedBody.job.id); - assert.equal(claimed.status, 200); - assert.equal(heartbeat.status, 200); - assert.equal(attached.status, 201); - assert.equal(attachedBody.data.recording.cached, true); - assert.equal(attachedBody.data.recording.status, "cached"); - assert.equal(attachedBody.data.recording.healthStatus, "healthy"); - assert.equal(attachedBody.data.recording.cachePath, `ad-hoc/${startedBody.data.id}.wav`); - assert.equal(attachedBody.data.recording.durationSeconds, 2); - assert.equal(attachedBody.data.recording.waveformPreview?.peaks.length, 4); - assert.equal(attachedBody.data.uploadQueueItem?.recordingId, startedBody.data.id); - assert.equal(attachedBody.data.uploadQueueItem?.uploadPolicyId, policy.id); - assert.equal(completedJob.status, 200); - assert.equal(completedJobBody.data.status, "completed"); - assert.equal(cached?.status, "cached"); - assert.equal(cached?.healthStatus, "healthy"); - assert.equal(playback.status, 202); - assert.equal(download.status, 202); - assert.equal(stream.status, 200); - assert.equal(stream.headers.get("content-type"), "audio/wav"); - assert.equal((await stream.arrayBuffer()).byteLength, wavBytes.byteLength); - assert.equal(file.status, 200); - assert.equal( - file.headers.get("content-disposition"), - 'attachment; filename="Lifecycle Recording.wav"', - ); - assert.equal(cacheAudit?.details.jobStatus, "completed"); - assert.equal(autoQueueAudit?.details.uploadPolicyId, policy.id); -}); - -test("claim-next lets one node claim multiple queued recordings independently", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Concurrent Lifecycle Recorder", - id: `node_concurrent_lifecycle_${randomUUID()}`, - interfaces: [ - { - alias: "Concurrent X32", - backend: "alsa" as const, - channelCount: 4, - channels: [ - { alias: "Ch 1", index: 1 }, - { alias: "Ch 2", index: 2 }, - { alias: "Ch 3", index: 3 }, - { alias: "Ch 4", index: 4 }, - ], - id: "iface_concurrent_x32", - sampleRates: [48_000], - systemName: "Concurrent X32", - systemRef: "hw:CARD=X32,DEV=0", - }, - ], - recordingCapacity: { maxConcurrentRecordings: 2 }, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const firstStarted = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - captureChannelSelection: [1, 2], - captureInterfaceId: "iface_concurrent_x32", - channelMode: "stereo", - name: "Concurrent Recording A", - nodeId: lifecycleNode.id, - tags: ["voice", "concurrent"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const secondStarted = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - captureChannelSelection: [3, 4], - captureInterfaceId: "iface_concurrent_x32", - channelMode: "stereo", - name: "Concurrent Recording B", - nodeId: lifecycleNode.id, - tags: ["voice", "concurrent"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const firstStartedBody = (await firstStarted.json()) as { - data: RecordingSummary; - job: RecordingJob; - }; - const secondStartedBody = (await secondStarted.json()) as { - data: RecordingSummary; - job: RecordingJob; - }; - const firstClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const secondClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const emptyClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const firstClaimBody = (await firstClaim.json()) as { data: RecordingJob }; - const secondClaimBody = (await secondClaim.json()) as { data: RecordingJob }; - const claimedJobIds = new Set([firstClaimBody.data.id, secondClaimBody.data.id]); - const expectedJobIds = new Set([firstStartedBody.job.id, secondStartedBody.job.id]); - const firstRecording = await recordingStore.find(firstStartedBody.data.id); - const secondRecording = await recordingStore.find(secondStartedBody.data.id); - const claimAudits = ( - await auditStore.list({ - action: "recording_jobs.claim_next.succeeded", - }) - ).filter((event) => event.target.type === "recording_job"); - assert.equal(firstStarted.status, 202); - assert.equal(secondStarted.status, 202); - assert.equal(firstClaim.status, 200); - assert.equal(secondClaim.status, 200); - assert.equal(emptyClaim.status, 204); - assert.deepEqual(claimedJobIds, expectedJobIds); - assert.equal(firstClaimBody.data.status, "running"); - assert.equal(secondClaimBody.data.status, "running"); - assert.equal(firstRecording?.status, "recording"); - assert.equal(secondRecording?.status, "recording"); - assert.equal(claimAudits.length, 2); -}); -test("controller stop request survives agent cancellation as completed recording", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Stop Lifecycle Recorder", - id: `node_stop_lifecycle_${randomUUID()}`, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const started = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - name: "Stop Lifecycle Recording", - nodeId: lifecycleNode.id, - tags: ["voice", "stop-lifecycle"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; - const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const stopped = await app.request(`/api/v1/recordings/${startedBody.data.id}/stop`, { - method: "POST", - }); - const stoppedBody = (await stopped.json()) as { data: RecordingSummary }; - const cancelled = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/cancelled`, { - headers: { - authorization: "Bearer node-token", - "x-rakkr-reason": "controller_stop_requested", - }, - method: "POST", - }); - const cancelledBody = (await cancelled.json()) as { data: RecordingJob }; - const updated = await recordingStore.find(startedBody.data.id); - const healthEvents = await healthEventStore.list({ recordingId: startedBody.data.id }); - const [stopAudit] = await auditStore.list({ action: "recordings.stop.succeeded" }); - const [cancelAudit] = await auditStore.list({ - action: "recording_jobs.cancelled.succeeded", - }); - - assert.equal(started.status, 202); - assert.equal(claimed.status, 200); - assert.equal(stopped.status, 200); - assert.equal(stoppedBody.data.status, "completed"); - assert.equal(stopAudit?.details.jobStatus, "stop_requested"); - assert.equal(cancelled.status, 200); - assert.equal(cancelledBody.data.status, "cancelled"); - assert.equal(updated?.status, "completed"); - assert.equal(updated?.healthStatus, "healthy"); - assert.equal(healthEvents.length, 0); - assert.equal(cancelAudit?.details.reason, "controller_stop_requested"); - assert.equal(cancelAudit?.details.recordingStatus, "completed"); -}); - -function memoryNodeStore(nodes: RecorderNode[] = [node()]): NodeStore { - return { - async authenticateCredential(token) { - return token === "node-token" - ? { - credentialId: "cred_agent_test", - nodeId: nodes[0]?.id ?? "node_agent_test", - tokenPrefix: "node-token", - } - : undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodes.find((candidate) => candidate.id === nodeId); - }, - async heartbeat(nodeId: string, input: NodeHeartbeatInput) { - const index = nodes.findIndex((candidate) => candidate.id === nodeId); - - if (index < 0) { - return undefined; - } - - nodes[index] = { - ...nodes[index], - agentVersion: input.agentVersion, - hostname: input.hostname, - ipAddresses: input.ipAddresses, - lastSeenAt: new Date().toISOString(), - runtime: input.runtime, - status: input.status, - }; - - return nodes[index]; - }, - async list() { - return nodes; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - }; -} - -function requirePermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function memoryMeterFrameStore(): MeterFrameStore { - const frames: MeterFrame[] = []; - - return { - async history(nodeId, limit = frames.length) { - return frames.filter((frame) => frame.nodeId === nodeId).slice(0, limit); - }, - async latest() { - return frames[0]; - }, - async save(frame) { - frames.unshift(frame); - - return { - frame, - receivedAt: new Date().toISOString(), - }; - }, - }; -} - -function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { - return { - async create(recording) { - recordings.unshift(recording); - }, - async delete(recordingId) { - const index = recordings.findIndex((candidate) => candidate.id === recordingId); - - if (index < 0) { - return undefined; - } - - const [deleted] = recordings.splice(index, 1); - - return deleted; - }, - async find(recordingId) { - return recordings.find((candidate) => candidate.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - - if (index >= 0) { - recordings[index] = recording; - } - }, - async transition(recording, allowedFrom) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - const current = recordings[index]; - - if (!current || !allowedFrom.includes(current.status)) { - return undefined; - } - - recordings[index] = recording; - - return recording; - }, - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: input.actor ?? { - id: "anonymous", - name: "Anonymous", - roles: [], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { - return { - async assignChannelMapTemplate() { - throw new Error("not implemented"); - }, - async createChannelMapTemplate() { - throw new Error("not implemented"); - }, - async findChannelMapTemplate() { - return undefined; - }, - async findRecordingProfile(profileId) { - return profiles.find((profile) => profile.id === profileId); - }, - async findWatchdogPolicy() { - return undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listChannelMapTemplates() { - return []; - }, - async listRecordingProfiles() { - return profiles; - }, - async listWatchdogPolicies() { - return []; - }, - async rollbackChannelMapAssignment() { - return undefined; - }, - async updateChannelMapTemplate() { - return undefined; - }, - async updateRecordingProfile() { - return undefined; - }, - async updateWatchdogPolicy() { - return undefined; - }, - }; -} - -function auth(): AuthResult { - return { user: user() }; -} - -function user(): CurrentUser { - return { - email: "agent-route@example.com", - groups: [], - id: "user_agent_route", - name: "Agent Route User", - permissions: [ - "recording:create", - "recording:download", - "recording:playback", - "recording:read", - ] satisfies Permission[], - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function node(): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Agent Route Node", - hostname: "agent-route-node", - id: "node_agent_test", - interfaces: [], - ipAddresses: ["127.0.0.1"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Test Room", - site: "Test Site", - }, - status: "recording", - tags: [], - }; -} - -function recording(): RecordingSummary { - return { - cached: false, - durationSeconds: 900, - folder: "Meetings/2026", - healthStatus: "unknown", - id: "rec_agent_failure", - name: "Agent Failure Test", - nodeId: "node_agent_test", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "schedule", - status: "recording", - tags: ["voice"], - }; -} - -function flacProfile(): RecordingProfile { - return { - bitrateKbps: 256, - channelMode: "stereo", - codec: "flac", - id: "voice-flac", - name: "Voice FLAC", - silenceDetectionEnabled: false, - silenceSkipEnabled: false, - vbr: false, - }; -} - -function wavFile(samples: number[]) { - const dataSize = samples.length * 2; - const buffer = Buffer.alloc(44 + dataSize); - - buffer.write("RIFF", 0, "ascii"); - buffer.writeUInt32LE(36 + dataSize, 4); - buffer.write("WAVE", 8, "ascii"); - buffer.write("fmt ", 12, "ascii"); - buffer.writeUInt32LE(16, 16); - buffer.writeUInt16LE(1, 20); - buffer.writeUInt16LE(1, 22); - buffer.writeUInt32LE(48_000, 24); - buffer.writeUInt32LE(96_000, 28); - buffer.writeUInt16LE(2, 32); - buffer.writeUInt16LE(16, 34); - buffer.write("data", 36, "ascii"); - buffer.writeUInt32LE(dataSize, 40); - - samples.forEach((sample, index) => buffer.writeInt16LE(sample, 44 + index * 2)); - - return buffer; -} diff --git a/apps/api/test/recording-job-export-harness.ts b/apps/api/test/recording-job-export-harness.ts new file mode 100644 index 00000000..f32939f4 --- /dev/null +++ b/apps/api/test/recording-job-export-harness.ts @@ -0,0 +1,272 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + MeterFrame, + RecordingJob, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; +import type { MeterFrameStore } from "../src/meter-store.js"; +import type { NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +export { memoryRecordingStore } from "./recording-store-mock.js"; + +export const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-job-export-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); +process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(routeRoot, "retention-policies.json"); + +export const { createAuditStore } = await import("../src/audit-store.js"); +export const { registerAgentRoutes } = await import("../src/agent-routes.js"); +export const { createHealthEventStore } = await import("../src/health-store.js"); +export const { filterRecordingJobsForExport, recordingJobsCsv } = + await import("../src/recording-job-export.js"); +export const { createRecordingJob, failRecordingJob } = await import("../src/recording-jobs.js"); +export const { registerRecordingRoutes } = await import("../src/recording-routes.js"); + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export interface PermissionCall { + action: string; + permission: Permission; + target?: AuditTarget; +} + +export interface RecordingJobActionsResponse { + data: { + actions: Record< + string, + { enabled: boolean; href?: string; payload?: Record; reason?: string } + >; + job: RecordingJob; + recording?: RecordingSummary; + retryConflict?: RecordingJob; + }; +} + +export function recordingApp({ + auditStore, + permissionCalls, + permissions, + recordingStore, + scopedRecordingSnapshots, + visibleRecordingIds, +}: { + auditStore: ReturnType; + permissionCalls: PermissionCall[]; + permissions?: Permission[]; + recordingStore: RecordingStore; + scopedRecordingSnapshots?: RecordingSummary[]; + visibleRecordingIds?: string[]; +}) { + const app = new Hono(); + const currentUser = user(permissions); + + registerRecordingRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: async () => [], + scopedRecordings: async () => { + const recordings = scopedRecordingSnapshots ?? (await recordingStore.list()); + + return visibleRecordingIds + ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) + : recordings; + }, + settingsStore: memorySettingsStore(), + }); + + return app; +} + +export function requirePermission(calls: PermissionCall[]): RequirePermission { + return (permission, action, target) => async (c, next) => { + calls.push({ + action, + permission, + target: target ? await target(c) : undefined, + }); + await next(); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: { + id: "user_recording_job_export", + name: "Recording Job Export User", + roles: ["operator"], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function memoryNodeStore(): NodeStore { + const node: RecorderNode = { + agentVersion: "0.1.0", + alias: "Room Recorder", + hostname: "room-recorder", + id: "node_1", + interfaces: [], + ipAddresses: ["10.1.2.3"], + lastSeenAt: "2026-06-20T12:00:00.000Z", + location: { room: "Room", site: "Main" }, + status: "online", + tags: [], + }; + + return { + async authenticateCredential() { + return undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodeId === node.id ? node : undefined; + }, + async heartbeat() { + throw new Error("not implemented"); + }, + async list() { + return [node]; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + }; +} + +export function memoryMeterFrameStore(): MeterFrameStore { + return { + async history(): Promise { + return []; + }, + async latest() { + return undefined; + }, + async save(frame) { + return { + frame, + receivedAt: new Date().toISOString(), + }; + }, + }; +} + +export function memorySettingsStore(): SettingsStore { + return { + async findRecordingProfile(profileId) { + return profileId === defaultVoiceRecordingProfile.id + ? defaultVoiceRecordingProfile + : undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listRecordingProfiles(): Promise { + return [defaultVoiceRecordingProfile]; + }, + } as SettingsStore; +} + +export function user(permissions: Permission[] = ["recording:read"]): CurrentUser { + return { + email: "recording-job-export@example.com", + groups: [], + id: "user_recording_job_export", + name: "Recording Job Export User", + permissions, + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function job(input: Partial = {}): RecordingJob { + return { + command: { + captureChannels: 2, + captureDevice: "hw:0,0", + captureFormat: "S16_LE", + captureSampleRate: 48000, + durationSeconds: 3600, + outputCodec: "wav", + outputFileName: "recording.wav", + type: "alsa_capture", + }, + createdAt: "2026-06-20T12:00:00.000Z", + id: "job_1", + nodeId: "node_1", + recordingId: "rec_1", + status: "queued", + ...input, + }; +} + +export function recordingSummary(input: Partial = {}): RecordingSummary { + return { + cached: false, + durationSeconds: 0, + folder: "tests", + healthStatus: "unknown", + id: "rec_1", + name: "Retry Recording", + nodeId: "node_1", + recordedAt: "2026-06-20T12:00:00.000Z", + source: "ad_hoc", + status: "recording", + tags: ["voice"], + ...input, + }; +} diff --git a/apps/api/test/recording-job-export-lifecycle.test.ts b/apps/api/test/recording-job-export-lifecycle.test.ts new file mode 100644 index 00000000..ca2a0110 --- /dev/null +++ b/apps/api/test/recording-job-export-lifecycle.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import test from "node:test"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + createRecordingJob, + failRecordingJob, + memoryRecordingStore, + recordingApp, + recordingSummary, + routeRoot, + type PermissionCall, +} from "./recording-job-export-harness.js"; + +test("recording job retry route is RBAC-gated audited and resets recording state", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recording = recordingSummary({ + cachePath: path.join(routeRoot, "stale.wav"), + cached: true, + checksum: "stale-checksum", + durationSeconds: 12, + healthStatus: "critical", + id: `rec_retry_${randomUUID()}`, + status: "failed", + }); + const recordingStore = memoryRecordingStore([recording]); + const sourceJob = await createRecordingJob(recording, { + captureDevice: "hw:Retry,0", + }); + + await failRecordingJob(sourceJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request(`/api/v1/recording-jobs/${sourceJob.id}/retry`, { + method: "POST", + }); + const body = (await response.json()) as { data: RecordingJob }; + const updatedRecording = await recordingStore.find(recording.id); + const [event] = await auditStore.list({ action: "recording_jobs.retry.succeeded" }); + + assert.equal(response.status, 201); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.retry"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: recording.id, + type: "recording", + }); + assert.notEqual(body.data.id, sourceJob.id); + assert.equal(body.data.status, "queued"); + assert.equal(body.data.recordingId, recording.id); + assert.equal(body.data.command.captureDevice, "hw:Retry,0"); + assert.equal(updatedRecording?.status, "recording"); + assert.equal(updatedRecording?.cached, false); + assert.equal(updatedRecording?.cachePath, undefined); + assert.equal(updatedRecording?.checksum, undefined); + assert.equal(updatedRecording?.durationSeconds, 0); + assert.equal(updatedRecording?.healthStatus, "unknown"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, recording.id); + assert.equal(event?.before?.jobId, sourceJob.id); + assert.equal(event?.after?.jobId, body.data.id); + assert.equal(event?.correlationIds?.sourceJobId, sourceJob.id); + assert.equal(event?.correlationIds?.retryJobId, body.data.id); +}); + +test("recording job bulk retry route retries visible terminal jobs and audits collection", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recordings = [ + recordingSummary({ + cached: true, + checksum: "old-a", + durationSeconds: 60, + healthStatus: "critical", + id: `rec_bulk_retry_a_${randomUUID()}`, + status: "failed", + }), + recordingSummary({ + cached: true, + checksum: "old-b", + durationSeconds: 30, + healthStatus: "warning", + id: `rec_bulk_retry_b_${randomUUID()}`, + status: "cancelled", + }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); + const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); + + await failRecordingJob(firstJob.id, "capture_failed"); + await failRecordingJob(secondJob.id, "operator_cancelled"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request("/api/v1/recording-jobs/bulk-retry", { + body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id, firstJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingJob[]; + meta: { retriedCount: number }; + }; + const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.succeeded" }); + + assert.equal(response.status, 201); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_retry"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "recording_job_collection", + type: "recording_collection", + }); + assert.equal(body.meta.retriedCount, 2); + assert.equal(body.data.length, 2); + assert(body.data.every((job) => job.status === "queued")); + assert.equal((await recordingStore.find(recordings[0].id))?.status, "recording"); + assert.equal((await recordingStore.find(recordings[0].id))?.cached, false); + assert.equal((await recordingStore.find(recordings[1].id))?.status, "recording"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, "recording_job_collection"); + assert.deepEqual(event?.details.sourceJobIds, [firstJob.id, secondJob.id]); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.retriedCount, 2); +}); + +test("recording job bulk stop route stops visible active jobs and audits collection", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recordings = [ + recordingSummary({ id: `rec_bulk_stop_a_${randomUUID()}`, status: "recording" }), + recordingSummary({ id: `rec_bulk_stop_b_${randomUUID()}`, status: "recording" }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); + const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request("/api/v1/recording-jobs/bulk-stop", { + body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingJob[]; + meta: { stoppedCount: number }; + }; + const [event] = await auditStore.list({ action: "recording_jobs.bulk_stop.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_stop"); + assert.equal(body.meta.stoppedCount, 2); + assert(body.data.every((job) => job.status === "stop_requested")); + assert.equal((await recordingStore.find(recordings[0].id))?.status, "completed"); + assert.equal((await recordingStore.find(recordings[1].id))?.status, "completed"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, "recording_job_collection"); + assert.equal(event?.details.stoppedCount, 2); +}); + +test("recording job bulk retry rejects hidden jobs before mutating", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_bulk_visible_${randomUUID()}`, status: "failed" }); + const hidden = recordingSummary({ id: `rec_bulk_hidden_${randomUUID()}`, status: "failed" }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const visibleJob = await createRecordingJob(visible); + const hiddenJob = await createRecordingJob(hidden); + + await failRecordingJob(visibleJob.id, "capture_failed"); + await failRecordingJob(hiddenJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + const response = await app.request("/api/v1/recording-jobs/bulk-retry", { + body: JSON.stringify({ jobIds: [visibleJob.id, hiddenJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.failed" }); + + assert.equal(response.status, 404); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_job_not_visible"); + assert.deepEqual(event?.details.hiddenIds, [hiddenJob.id]); +}); diff --git a/apps/api/test/recording-job-export-read.test.ts b/apps/api/test/recording-job-export-read.test.ts new file mode 100644 index 00000000..ac3398da --- /dev/null +++ b/apps/api/test/recording-job-export-read.test.ts @@ -0,0 +1,352 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + createAuditStore, + createHealthEventStore, + createRecordingJob, + failRecordingJob, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + memorySettingsStore, + recordAuditEvent, + recordingApp, + recordingSummary, + registerAgentRoutes, + registerRecordingRoutes, + requirePermission, + user, + type PermissionCall, + type RecordingJobActionsResponse, +} from "./recording-job-export-harness.js"; + +test("recording job list route filters scoped jobs by status search node and backend", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const prefix = `filter_${randomUUID()}`; + const recordings = [ + recordingSummary({ id: `rec_${prefix}_jack`, nodeId: `${prefix}_node_jack` }), + recordingSummary({ id: `rec_${prefix}_pipewire`, nodeId: `${prefix}_node_pipewire` }), + recordingSummary({ id: `rec_${prefix}_failed`, nodeId: `${prefix}_node_failed` }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const jackJob = await createRecordingJob(recordings[0] as RecordingSummary, { + captureBackend: "jack", + captureDevice: `${prefix}:system:capture_1`, + captureInterfaceId: `${prefix}_iface_jack`, + }); + + await createRecordingJob(recordings[1] as RecordingSummary, { + captureBackend: "pipewire", + captureDevice: `${prefix}:alsa_input.usb-recorder`, + captureInterfaceId: `${prefix}_iface_pipewire`, + }); + const failedJob = await createRecordingJob(recordings[2] as RecordingSummary, { + captureBackend: "jack", + captureDevice: `${prefix}:system:capture_2`, + captureInterfaceId: `${prefix}_iface_other`, + }); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request( + `/api/v1/recording-jobs?search=${prefix}&status=queued&nodeId=${prefix}_node_jack&captureBackend=jack&captureInterfaceId=${prefix}_iface_jack&createdFrom=2026-06-01T00%3A00%3A00.000Z&createdTo=2099-12-31T23%3A59%3A59.999Z`, + ); + const body = (await response.json()) as { data: RecordingJob[] }; + const invalidResponse = await app.request("/api/v1/recording-jobs?createdFrom=2026-06-20"); + const [successEvent] = await auditStore.list({ action: "recording_jobs.read.succeeded" }); + const [failedEvent] = await auditStore.list({ action: "recording_jobs.read.failed" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:read"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.read"); + assert.deepEqual( + body.data.map((recordingJob) => recordingJob.id), + [jackJob.id], + ); + assert.equal(invalidResponse.status, 400); + assert.equal(successEvent?.target.id, "recording_job_collection"); + assert.equal(successEvent?.details.returnedCount, 1); + assert.equal(successEvent?.details.filters.status, "queued"); + assert.equal(failedEvent?.target.id, "recording_job_collection"); + assert.equal(failedEvent?.reason, "invalid_filters"); + assert.equal(failedEvent?.details.issueCount, 1); +}); + +test("recording job detail route returns only scoped jobs", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_detail_visible_${randomUUID()}` }); + const hidden = recordingSummary({ id: `rec_detail_hidden_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const visibleJob = await createRecordingJob(visible); + const hiddenJob = await createRecordingJob(hidden); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + + const visibleResponse = await app.request(`/api/v1/recording-jobs/${visibleJob.id}`); + const hiddenResponse = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}`); + const missingResponse = await app.request("/api/v1/recording-jobs/job_missing_detail"); + const visibleBody = (await visibleResponse.json()) as { data: RecordingJob }; + const [successEvent] = await auditStore.list({ + action: "recording_jobs.detail.read.succeeded", + }); + const failedEvents = await auditStore.list({ action: "recording_jobs.detail.read.failed" }); + + assert.equal(visibleResponse.status, 200); + assert.equal(visibleBody.data.id, visibleJob.id); + assert.equal(visibleBody.data.recordingId, visible.id); + assert.equal(hiddenResponse.status, 404); + assert.equal(missingResponse.status, 404); + assert.deepEqual(permissionCalls.at(-3), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: visible.id, type: "recording" }, + }); + assert.deepEqual(permissionCalls.at(-2), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: hidden.id, type: "recording" }, + }); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: "job_missing_detail", type: "recording_job" }, + }); + assert.equal(successEvent?.target.id, visibleJob.id); + assert.equal(successEvent?.correlationIds.recordingId, visible.id); + assert.equal(successEvent?.details.recordingId, visible.id); + assert.equal(successEvent?.details.status, visibleJob.status); + assert.deepEqual(failedEvents.map((event) => [event.target.id, event.reason]).sort(), [ + [hiddenJob.id, "recording_job_not_found"], + ["job_missing_detail", "recording_job_not_found"], + ]); +}); + +test("recording job detail route lets controller bearer reads pass agent route", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([recordingSummary()]); + const permissionCalls: PermissionCall[] = []; + const job = await createRecordingJob((await recordingStore.list())[0]!); + + registerAgentRoutes({ + app, + healthEventStore: createHealthEventStore("", []), + meterFrameStore: memoryMeterFrameStore(), + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore(), + }); + registerRecordingRoutes({ + app, + currentAuth: () => ({ user: user() }), + currentUser: () => user(), + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: async () => [], + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore(), + }); + + const response = await app.request(`/api/v1/recording-jobs/${job.id}`, { + headers: { authorization: "Bearer user-token" }, + }); + const body = (await response.json()) as { data: RecordingJob }; + const missingTokenEvents = await auditStore.list({ + action: "recording_jobs.read_one.failed", + }); + + assert.equal(response.status, 200); + assert.equal(body.data.id, job.id); + assert.equal(body.data.recordingId, recordingSummary().id); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.detail.read"); + assert.equal(missingTokenEvents.length, 0); +}); + +test("recording job action summary returns readiness links and payloads", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recording = recordingSummary({ id: `rec_job_actions_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + permissions: ["recording:control", "recording:read"], + recordingStore, + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + const [auditEvent] = await auditStore.list({ + action: "recording_jobs.actions.read.succeeded", + }); + + assert.equal(response.status, 200); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.actions.read", + permission: "recording:read", + target: { id: recording.id, type: "recording" }, + }); + assert.equal(body.data.job.id, failedJob.id); + assert.equal(body.data.recording?.id, recording.id); + assert.equal(body.data.retryConflict, undefined); + assert.equal(body.data.actions.detail.enabled, true); + assert.equal(body.data.actions.exportSelected.enabled, true); + assert.deepEqual(body.data.actions.exportSelected.payload, { jobIds: [failedJob.id] }); + assert.equal(body.data.actions.retry.enabled, true); + assert.equal(body.data.actions.retry.href, `/api/v1/recording-jobs/${failedJob.id}/retry`); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); + assert.deepEqual(body.data.actions.stop.payload, { jobIds: [failedJob.id] }); + assert.equal(auditEvent?.outcome, "succeeded"); + assert.equal(auditEvent?.permission, "recording:read"); + assert.equal(auditEvent?.target.id, failedJob.id); + assert.equal(auditEvent?.target.type, "recording_job"); + assert.equal(auditEvent?.correlationIds?.recordingId, recording.id); + assert.equal(auditEvent?.details.recordingAvailable, true); + assert.equal(auditEvent?.details.status, "failed"); + assert.equal(auditEvent?.details.visibleActionCount, 4); +}); + +test("recording job action summary uses scoped recording context for readiness", async () => { + const auditStore = createAuditStore(""); + const scopedRecording = recordingSummary({ + id: `rec_job_scoped_context_${randomUUID()}`, + name: "Scoped Recording Context", + }); + const rawRecording = recordingSummary({ + id: scopedRecording.id, + name: "Raw Recording Context", + }); + const recordingStore = memoryRecordingStore([rawRecording]); + const failedJob = await createRecordingJob(scopedRecording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:control", "recording:read"], + recordingStore, + scopedRecordingSnapshots: [scopedRecording], + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.job.id, failedJob.id); + assert.equal(body.data.recording?.id, scopedRecording.id); + assert.equal(body.data.recording?.name, "Scoped Recording Context"); + assert.equal(body.data.actions.retry.enabled, true); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); +}); + +test("recording job action summary explains lifecycle and dependency blockers", async () => { + const auditStore = createAuditStore(""); + const recording = recordingSummary({ id: `rec_job_blockers_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const queuedJob = await createRecordingJob(recording); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:control", "recording:read"], + recordingStore, + }); + + const queuedResponse = await app.request(`/api/v1/recording-jobs/${queuedJob.id}/actions`); + const failedResponse = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const queuedBody = (await queuedResponse.json()) as RecordingJobActionsResponse; + const failedBody = (await failedResponse.json()) as RecordingJobActionsResponse; + + assert.equal(queuedResponse.status, 200); + assert.equal(queuedBody.data.actions.stop.enabled, true); + assert.equal(queuedBody.data.actions.retry.enabled, false); + assert.equal(queuedBody.data.actions.retry.reason, "recording_job_not_retryable"); + assert.equal(failedResponse.status, 200); + assert.equal(failedBody.data.actions.retry.enabled, false); + assert.equal(failedBody.data.actions.retry.reason, "active_job_exists"); + assert.equal(failedBody.data.retryConflict?.id, queuedJob.id); +}); + +test("recording job action summary reports missing permission before lifecycle readiness", async () => { + const auditStore = createAuditStore(""); + const recording = recordingSummary({ id: `rec_job_permission_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:read"], + recordingStore, + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.actions.detail.enabled, true); + assert.equal(body.data.actions.retry.enabled, false); + assert.equal(body.data.actions.retry.reason, "missing_permission"); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "missing_permission"); +}); + +test("recording job action summary hides jobs outside scoped visibility", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_job_action_visible_${randomUUID()}` }); + const hidden = recordingSummary({ id: `rec_job_action_hidden_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const hiddenJob = await createRecordingJob(hidden); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + + const response = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}/actions`); + const [auditEvent] = await auditStore.list({ action: "recording_jobs.actions.read.failed" }); + + assert.equal(response.status, 404); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.actions.read", + permission: "recording:read", + target: { id: hidden.id, type: "recording" }, + }); + assert.equal(auditEvent?.outcome, "failed"); + assert.equal(auditEvent?.permission, "recording:read"); + assert.equal(auditEvent?.reason, "recording_job_not_found"); + assert.equal(auditEvent?.target.id, hiddenJob.id); + assert.equal(auditEvent?.target.type, "recording_job"); +}); diff --git a/apps/api/test/recording-job-export.test.ts b/apps/api/test/recording-job-export.test.ts index f594ed21..d1263061 100644 --- a/apps/api/test/recording-job-export.test.ts +++ b/apps/api/test/recording-job-export.test.ts @@ -1,49 +1,18 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - MeterFrame, - RecordingJob, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; -import type { MeterFrameStore } from "../src/meter-store.js"; -import type { NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; -import type { SettingsStore } from "../src/settings-store.js"; -import { memoryRecordingStore } from "./recording-store-mock.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-job-export-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); -process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(routeRoot, "retention-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerAgentRoutes } = await import("../src/agent-routes.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { filterRecordingJobsForExport, recordingJobsCsv } = - await import("../src/recording-job-export.js"); -const { createRecordingJob, failRecordingJob } = await import("../src/recording-jobs.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + createRecordingJob, + filterRecordingJobsForExport, + job, + memoryRecordingStore, + recordingApp, + recordingJobsCsv, + recordingSummary, + type PermissionCall, +} from "./recording-job-export-harness.js"; test("recording job export helpers filter and render csv", () => { const visibleJob = job({ @@ -89,333 +58,6 @@ test("recording job export helpers filter and render csv", () => { assert.doesNotMatch(csv, /job_other/); }); -test("recording job list route filters scoped jobs by status search node and backend", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const prefix = `filter_${randomUUID()}`; - const recordings = [ - recordingSummary({ id: `rec_${prefix}_jack`, nodeId: `${prefix}_node_jack` }), - recordingSummary({ id: `rec_${prefix}_pipewire`, nodeId: `${prefix}_node_pipewire` }), - recordingSummary({ id: `rec_${prefix}_failed`, nodeId: `${prefix}_node_failed` }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const jackJob = await createRecordingJob(recordings[0] as RecordingSummary, { - captureBackend: "jack", - captureDevice: `${prefix}:system:capture_1`, - captureInterfaceId: `${prefix}_iface_jack`, - }); - - await createRecordingJob(recordings[1] as RecordingSummary, { - captureBackend: "pipewire", - captureDevice: `${prefix}:alsa_input.usb-recorder`, - captureInterfaceId: `${prefix}_iface_pipewire`, - }); - const failedJob = await createRecordingJob(recordings[2] as RecordingSummary, { - captureBackend: "jack", - captureDevice: `${prefix}:system:capture_2`, - captureInterfaceId: `${prefix}_iface_other`, - }); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request( - `/api/v1/recording-jobs?search=${prefix}&status=queued&nodeId=${prefix}_node_jack&captureBackend=jack&captureInterfaceId=${prefix}_iface_jack&createdFrom=2026-06-01T00%3A00%3A00.000Z&createdTo=2099-12-31T23%3A59%3A59.999Z`, - ); - const body = (await response.json()) as { data: RecordingJob[] }; - const invalidResponse = await app.request("/api/v1/recording-jobs?createdFrom=2026-06-20"); - const [successEvent] = await auditStore.list({ action: "recording_jobs.read.succeeded" }); - const [failedEvent] = await auditStore.list({ action: "recording_jobs.read.failed" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:read"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.read"); - assert.deepEqual( - body.data.map((recordingJob) => recordingJob.id), - [jackJob.id], - ); - assert.equal(invalidResponse.status, 400); - assert.equal(successEvent?.target.id, "recording_job_collection"); - assert.equal(successEvent?.details.returnedCount, 1); - assert.equal(successEvent?.details.filters.status, "queued"); - assert.equal(failedEvent?.target.id, "recording_job_collection"); - assert.equal(failedEvent?.reason, "invalid_filters"); - assert.equal(failedEvent?.details.issueCount, 1); -}); - -test("recording job detail route returns only scoped jobs", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_detail_visible_${randomUUID()}` }); - const hidden = recordingSummary({ id: `rec_detail_hidden_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const visibleJob = await createRecordingJob(visible); - const hiddenJob = await createRecordingJob(hidden); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - - const visibleResponse = await app.request(`/api/v1/recording-jobs/${visibleJob.id}`); - const hiddenResponse = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}`); - const missingResponse = await app.request("/api/v1/recording-jobs/job_missing_detail"); - const visibleBody = (await visibleResponse.json()) as { data: RecordingJob }; - const [successEvent] = await auditStore.list({ - action: "recording_jobs.detail.read.succeeded", - }); - const failedEvents = await auditStore.list({ action: "recording_jobs.detail.read.failed" }); - - assert.equal(visibleResponse.status, 200); - assert.equal(visibleBody.data.id, visibleJob.id); - assert.equal(visibleBody.data.recordingId, visible.id); - assert.equal(hiddenResponse.status, 404); - assert.equal(missingResponse.status, 404); - assert.deepEqual(permissionCalls.at(-3), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: visible.id, type: "recording" }, - }); - assert.deepEqual(permissionCalls.at(-2), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: hidden.id, type: "recording" }, - }); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: "job_missing_detail", type: "recording_job" }, - }); - assert.equal(successEvent?.target.id, visibleJob.id); - assert.equal(successEvent?.correlationIds.recordingId, visible.id); - assert.equal(successEvent?.details.recordingId, visible.id); - assert.equal(successEvent?.details.status, visibleJob.status); - assert.deepEqual(failedEvents.map((event) => [event.target.id, event.reason]).sort(), [ - [hiddenJob.id, "recording_job_not_found"], - ["job_missing_detail", "recording_job_not_found"], - ]); -}); - -test("recording job detail route lets controller bearer reads pass agent route", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([recordingSummary()]); - const permissionCalls: PermissionCall[] = []; - const job = await createRecordingJob((await recordingStore.list())[0]!); - - registerAgentRoutes({ - app, - healthEventStore: createHealthEventStore("", []), - meterFrameStore: memoryMeterFrameStore(), - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore(), - }); - registerRecordingRoutes({ - app, - currentAuth: () => ({ user: user() }), - currentUser: () => user(), - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: async () => [], - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore(), - }); - - const response = await app.request(`/api/v1/recording-jobs/${job.id}`, { - headers: { authorization: "Bearer user-token" }, - }); - const body = (await response.json()) as { data: RecordingJob }; - const missingTokenEvents = await auditStore.list({ - action: "recording_jobs.read_one.failed", - }); - - assert.equal(response.status, 200); - assert.equal(body.data.id, job.id); - assert.equal(body.data.recordingId, recordingSummary().id); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.detail.read"); - assert.equal(missingTokenEvents.length, 0); -}); - -test("recording job action summary returns readiness links and payloads", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recording = recordingSummary({ id: `rec_job_actions_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - permissions: ["recording:control", "recording:read"], - recordingStore, - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - const [auditEvent] = await auditStore.list({ - action: "recording_jobs.actions.read.succeeded", - }); - - assert.equal(response.status, 200); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.actions.read", - permission: "recording:read", - target: { id: recording.id, type: "recording" }, - }); - assert.equal(body.data.job.id, failedJob.id); - assert.equal(body.data.recording?.id, recording.id); - assert.equal(body.data.retryConflict, undefined); - assert.equal(body.data.actions.detail.enabled, true); - assert.equal(body.data.actions.exportSelected.enabled, true); - assert.deepEqual(body.data.actions.exportSelected.payload, { jobIds: [failedJob.id] }); - assert.equal(body.data.actions.retry.enabled, true); - assert.equal(body.data.actions.retry.href, `/api/v1/recording-jobs/${failedJob.id}/retry`); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); - assert.deepEqual(body.data.actions.stop.payload, { jobIds: [failedJob.id] }); - assert.equal(auditEvent?.outcome, "succeeded"); - assert.equal(auditEvent?.permission, "recording:read"); - assert.equal(auditEvent?.target.id, failedJob.id); - assert.equal(auditEvent?.target.type, "recording_job"); - assert.equal(auditEvent?.correlationIds?.recordingId, recording.id); - assert.equal(auditEvent?.details.recordingAvailable, true); - assert.equal(auditEvent?.details.status, "failed"); - assert.equal(auditEvent?.details.visibleActionCount, 4); -}); - -test("recording job action summary uses scoped recording context for readiness", async () => { - const auditStore = createAuditStore(""); - const scopedRecording = recordingSummary({ - id: `rec_job_scoped_context_${randomUUID()}`, - name: "Scoped Recording Context", - }); - const rawRecording = recordingSummary({ - id: scopedRecording.id, - name: "Raw Recording Context", - }); - const recordingStore = memoryRecordingStore([rawRecording]); - const failedJob = await createRecordingJob(scopedRecording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:control", "recording:read"], - recordingStore, - scopedRecordingSnapshots: [scopedRecording], - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.job.id, failedJob.id); - assert.equal(body.data.recording?.id, scopedRecording.id); - assert.equal(body.data.recording?.name, "Scoped Recording Context"); - assert.equal(body.data.actions.retry.enabled, true); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); -}); - -test("recording job action summary explains lifecycle and dependency blockers", async () => { - const auditStore = createAuditStore(""); - const recording = recordingSummary({ id: `rec_job_blockers_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const queuedJob = await createRecordingJob(recording); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:control", "recording:read"], - recordingStore, - }); - - const queuedResponse = await app.request(`/api/v1/recording-jobs/${queuedJob.id}/actions`); - const failedResponse = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const queuedBody = (await queuedResponse.json()) as RecordingJobActionsResponse; - const failedBody = (await failedResponse.json()) as RecordingJobActionsResponse; - - assert.equal(queuedResponse.status, 200); - assert.equal(queuedBody.data.actions.stop.enabled, true); - assert.equal(queuedBody.data.actions.retry.enabled, false); - assert.equal(queuedBody.data.actions.retry.reason, "recording_job_not_retryable"); - assert.equal(failedResponse.status, 200); - assert.equal(failedBody.data.actions.retry.enabled, false); - assert.equal(failedBody.data.actions.retry.reason, "active_job_exists"); - assert.equal(failedBody.data.retryConflict?.id, queuedJob.id); -}); - -test("recording job action summary reports missing permission before lifecycle readiness", async () => { - const auditStore = createAuditStore(""); - const recording = recordingSummary({ id: `rec_job_permission_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:read"], - recordingStore, - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.actions.detail.enabled, true); - assert.equal(body.data.actions.retry.enabled, false); - assert.equal(body.data.actions.retry.reason, "missing_permission"); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "missing_permission"); -}); - -test("recording job action summary hides jobs outside scoped visibility", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_job_action_visible_${randomUUID()}` }); - const hidden = recordingSummary({ id: `rec_job_action_hidden_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const hiddenJob = await createRecordingJob(hidden); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - - const response = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}/actions`); - const [auditEvent] = await auditStore.list({ action: "recording_jobs.actions.read.failed" }); - - assert.equal(response.status, 404); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.actions.read", - permission: "recording:read", - target: { id: hidden.id, type: "recording" }, - }); - assert.equal(auditEvent?.outcome, "failed"); - assert.equal(auditEvent?.permission, "recording:read"); - assert.equal(auditEvent?.reason, "recording_job_not_found"); - assert.equal(auditEvent?.target.id, hiddenJob.id); - assert.equal(auditEvent?.target.type, "recording_job"); -}); - test("recording job export route is RBAC-gated and audited", async () => { const auditStore = createAuditStore(""); const permissionCalls: PermissionCall[] = []; @@ -537,195 +179,6 @@ test("recording job selected export rejects hidden jobs before exporting", async assert.deepEqual(event?.details.jobIds, [visibleJob.id, hiddenJob.id]); }); -test("recording job retry route is RBAC-gated audited and resets recording state", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recording = recordingSummary({ - cachePath: path.join(routeRoot, "stale.wav"), - cached: true, - checksum: "stale-checksum", - durationSeconds: 12, - healthStatus: "critical", - id: `rec_retry_${randomUUID()}`, - status: "failed", - }); - const recordingStore = memoryRecordingStore([recording]); - const sourceJob = await createRecordingJob(recording, { - captureDevice: "hw:Retry,0", - }); - - await failRecordingJob(sourceJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request(`/api/v1/recording-jobs/${sourceJob.id}/retry`, { - method: "POST", - }); - const body = (await response.json()) as { data: RecordingJob }; - const updatedRecording = await recordingStore.find(recording.id); - const [event] = await auditStore.list({ action: "recording_jobs.retry.succeeded" }); - - assert.equal(response.status, 201); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.retry"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: recording.id, - type: "recording", - }); - assert.notEqual(body.data.id, sourceJob.id); - assert.equal(body.data.status, "queued"); - assert.equal(body.data.recordingId, recording.id); - assert.equal(body.data.command.captureDevice, "hw:Retry,0"); - assert.equal(updatedRecording?.status, "recording"); - assert.equal(updatedRecording?.cached, false); - assert.equal(updatedRecording?.cachePath, undefined); - assert.equal(updatedRecording?.checksum, undefined); - assert.equal(updatedRecording?.durationSeconds, 0); - assert.equal(updatedRecording?.healthStatus, "unknown"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, recording.id); - assert.equal(event?.before?.jobId, sourceJob.id); - assert.equal(event?.after?.jobId, body.data.id); - assert.equal(event?.correlationIds?.sourceJobId, sourceJob.id); - assert.equal(event?.correlationIds?.retryJobId, body.data.id); -}); - -test("recording job bulk retry route retries visible terminal jobs and audits collection", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recordings = [ - recordingSummary({ - cached: true, - checksum: "old-a", - durationSeconds: 60, - healthStatus: "critical", - id: `rec_bulk_retry_a_${randomUUID()}`, - status: "failed", - }), - recordingSummary({ - cached: true, - checksum: "old-b", - durationSeconds: 30, - healthStatus: "warning", - id: `rec_bulk_retry_b_${randomUUID()}`, - status: "cancelled", - }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); - const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); - - await failRecordingJob(firstJob.id, "capture_failed"); - await failRecordingJob(secondJob.id, "operator_cancelled"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request("/api/v1/recording-jobs/bulk-retry", { - body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id, firstJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingJob[]; - meta: { retriedCount: number }; - }; - const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.succeeded" }); - - assert.equal(response.status, 201); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_retry"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "recording_job_collection", - type: "recording_collection", - }); - assert.equal(body.meta.retriedCount, 2); - assert.equal(body.data.length, 2); - assert(body.data.every((job) => job.status === "queued")); - assert.equal((await recordingStore.find(recordings[0].id))?.status, "recording"); - assert.equal((await recordingStore.find(recordings[0].id))?.cached, false); - assert.equal((await recordingStore.find(recordings[1].id))?.status, "recording"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, "recording_job_collection"); - assert.deepEqual(event?.details.sourceJobIds, [firstJob.id, secondJob.id]); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.retriedCount, 2); -}); - -test("recording job bulk stop route stops visible active jobs and audits collection", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recordings = [ - recordingSummary({ id: `rec_bulk_stop_a_${randomUUID()}`, status: "recording" }), - recordingSummary({ id: `rec_bulk_stop_b_${randomUUID()}`, status: "recording" }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); - const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request("/api/v1/recording-jobs/bulk-stop", { - body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingJob[]; - meta: { stoppedCount: number }; - }; - const [event] = await auditStore.list({ action: "recording_jobs.bulk_stop.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_stop"); - assert.equal(body.meta.stoppedCount, 2); - assert(body.data.every((job) => job.status === "stop_requested")); - assert.equal((await recordingStore.find(recordings[0].id))?.status, "completed"); - assert.equal((await recordingStore.find(recordings[1].id))?.status, "completed"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, "recording_job_collection"); - assert.equal(event?.details.stoppedCount, 2); -}); - -test("recording job bulk retry rejects hidden jobs before mutating", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_bulk_visible_${randomUUID()}`, status: "failed" }); - const hidden = recordingSummary({ id: `rec_bulk_hidden_${randomUUID()}`, status: "failed" }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const visibleJob = await createRecordingJob(visible); - const hiddenJob = await createRecordingJob(hidden); - - await failRecordingJob(visibleJob.id, "capture_failed"); - await failRecordingJob(hiddenJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - const response = await app.request("/api/v1/recording-jobs/bulk-retry", { - body: JSON.stringify({ jobIds: [visibleJob.id, hiddenJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.failed" }); - - assert.equal(response.status, 404); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_job_not_visible"); - assert.deepEqual(event?.details.hiddenIds, [hiddenJob.id]); -}); - test("recording job export search matches capture backend", () => { const filtered = filterRecordingJobsForExport( [ @@ -748,227 +201,3 @@ test("recording job export search matches capture backend", () => { ["job_pipewire"], ); }); - -interface PermissionCall { - action: string; - permission: Permission; - target?: AuditTarget; -} - -interface RecordingJobActionsResponse { - data: { - actions: Record< - string, - { enabled: boolean; href?: string; payload?: Record; reason?: string } - >; - job: RecordingJob; - recording?: RecordingSummary; - retryConflict?: RecordingJob; - }; -} - -function recordingApp({ - auditStore, - permissionCalls, - permissions, - recordingStore, - scopedRecordingSnapshots, - visibleRecordingIds, -}: { - auditStore: ReturnType; - permissionCalls: PermissionCall[]; - permissions?: Permission[]; - recordingStore: RecordingStore; - scopedRecordingSnapshots?: RecordingSummary[]; - visibleRecordingIds?: string[]; -}) { - const app = new Hono(); - const currentUser = user(permissions); - - registerRecordingRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: async () => [], - scopedRecordings: async () => { - const recordings = scopedRecordingSnapshots ?? (await recordingStore.list()); - - return visibleRecordingIds - ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) - : recordings; - }, - settingsStore: memorySettingsStore(), - }); - - return app; -} - -function requirePermission(calls: PermissionCall[]): RequirePermission { - return (permission, action, target) => async (c, next) => { - calls.push({ - action, - permission, - target: target ? await target(c) : undefined, - }); - await next(); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: { - id: "user_recording_job_export", - name: "Recording Job Export User", - roles: ["operator"], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memoryNodeStore(): NodeStore { - const node: RecorderNode = { - agentVersion: "0.1.0", - alias: "Room Recorder", - hostname: "room-recorder", - id: "node_1", - interfaces: [], - ipAddresses: ["10.1.2.3"], - lastSeenAt: "2026-06-20T12:00:00.000Z", - location: { room: "Room", site: "Main" }, - status: "online", - tags: [], - }; - - return { - async authenticateCredential() { - return undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodeId === node.id ? node : undefined; - }, - async heartbeat() { - throw new Error("not implemented"); - }, - async list() { - return [node]; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - }; -} - -function memoryMeterFrameStore(): MeterFrameStore { - return { - async history(): Promise { - return []; - }, - async latest() { - return undefined; - }, - async save(frame) { - return { - frame, - receivedAt: new Date().toISOString(), - }; - }, - }; -} - -function memorySettingsStore(): SettingsStore { - return { - async findRecordingProfile(profileId) { - return profileId === defaultVoiceRecordingProfile.id - ? defaultVoiceRecordingProfile - : undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listRecordingProfiles(): Promise { - return [defaultVoiceRecordingProfile]; - }, - } as SettingsStore; -} - -function user(permissions: Permission[] = ["recording:read"]): CurrentUser { - return { - email: "recording-job-export@example.com", - groups: [], - id: "user_recording_job_export", - name: "Recording Job Export User", - permissions, - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function job(input: Partial = {}): RecordingJob { - return { - command: { - captureChannels: 2, - captureDevice: "hw:0,0", - captureFormat: "S16_LE", - captureSampleRate: 48000, - durationSeconds: 3600, - outputCodec: "wav", - outputFileName: "recording.wav", - type: "alsa_capture", - }, - createdAt: "2026-06-20T12:00:00.000Z", - id: "job_1", - nodeId: "node_1", - recordingId: "rec_1", - status: "queued", - ...input, - }; -} - -function recordingSummary(input: Partial = {}): RecordingSummary { - return { - cached: false, - durationSeconds: 0, - folder: "tests", - healthStatus: "unknown", - id: "rec_1", - name: "Retry Recording", - nodeId: "node_1", - recordedAt: "2026-06-20T12:00:00.000Z", - source: "ad_hoc", - status: "recording", - tags: ["voice"], - ...input, - }; -} diff --git a/apps/api/test/recording-routes-delete.test.ts b/apps/api/test/recording-routes-delete.test.ts new file mode 100644 index 00000000..35ab5f51 --- /dev/null +++ b/apps/api/test/recording-routes-delete.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, + routeRoot, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; + +test("bulk recording delete removes terminal recordings and audits one snapshot", async () => { + const auditStore = createAuditStore(""); + const cachePath = "ad-hoc/rec_bulk_delete_cached.mp3"; + const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); + const recordingStore = memoryRecordingStore([ + recording({ + cached: true, + cachePath, + id: "rec_bulk_delete_cached", + status: "cached", + }), + recording({ id: "rec_bulk_delete_terminal", status: "completed" }), + recording({ id: "rec_bulk_delete_keep", status: "completed" }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + await mkdir(path.dirname(cacheFilePath), { recursive: true }); + await writeFile(cacheFilePath, Buffer.from("cached audio")); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: [ + "rec_bulk_delete_cached", + "rec_bulk_delete_terminal", + "rec_bulk_delete_cached", + ], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingSummary[]; + meta: { cacheDeletedCount: number; deletedCount: number }; + }; + const deletedCached = await recordingStore.find("rec_bulk_delete_cached"); + const deletedTerminal = await recordingStore.find("rec_bulk_delete_terminal"); + const kept = await recordingStore.find("rec_bulk_delete_keep"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.succeeded" }); + const before = event?.before as { recordings?: RecordingSummary[] } | undefined; + + assert.equal(response.status, 200); + assert.equal(deletedCached, undefined); + assert.equal(deletedTerminal, undefined); + assert.equal(kept?.id, "rec_bulk_delete_keep"); + await assert.rejects( + readFile(cacheFilePath), + (error) => error instanceof Error && "code" in error && error.code === "ENOENT", + ); + assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.bulk_delete"); + assert.equal(body.meta.deletedCount, 2); + assert.equal(body.meta.cacheDeletedCount, 1); + assert.deepEqual( + body.data.map((recording) => recording.id), + ["rec_bulk_delete_cached", "rec_bulk_delete_terminal"], + ); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.target.type, "recording_collection"); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.deletedCount, 2); + assert.equal(before?.recordings?.[0]?.id, "rec_bulk_delete_cached"); +}); + +test("bulk recording delete rejects hidden recordings", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ id: "rec_bulk_delete_visible", status: "completed" }), + recording({ id: "rec_bulk_delete_hidden", status: "completed" }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + visibleRecordingIds: ["rec_bulk_delete_visible"], + }); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: ["rec_bulk_delete_visible", "rec_bulk_delete_hidden"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const visible = await recordingStore.find("rec_bulk_delete_visible"); + const hidden = await recordingStore.find("rec_bulk_delete_hidden"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); + + assert.equal(response.status, 404); + assert.equal(visible?.id, "rec_bulk_delete_visible"); + assert.equal(hidden?.id, "rec_bulk_delete_hidden"); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_not_visible"); + assert.deepEqual(event?.details.hiddenIds, ["rec_bulk_delete_hidden"]); +}); + +test("bulk recording delete rejects active recordings before deleting anything", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ id: "rec_bulk_delete_ready", status: "completed" }), + recording({ id: "rec_bulk_delete_active", status: "recording" }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: ["rec_bulk_delete_ready", "rec_bulk_delete_active"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const ready = await recordingStore.find("rec_bulk_delete_ready"); + const active = await recordingStore.find("rec_bulk_delete_active"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); + + assert.equal(response.status, 409); + assert.equal(ready?.id, "rec_bulk_delete_ready"); + assert.equal(active?.id, "rec_bulk_delete_active"); + assert.equal(event?.reason, "recording_active"); + assert.deepEqual(event?.details.activeIds, ["rec_bulk_delete_active"]); +}); + +test("recording delete removes terminal metadata cached file and audits snapshot", async () => { + const auditStore = createAuditStore(""); + const cachePath = "ad-hoc/rec_delete_cached.mp3"; + const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); + const recordingStore = memoryRecordingStore([ + recording({ + cached: true, + cachePath, + id: "rec_delete_cached", + name: "Delete Cached Recording", + status: "cached", + }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + await mkdir(path.dirname(cacheFilePath), { recursive: true }); + await writeFile(cacheFilePath, Buffer.from("cached audio")); + + const response = await app.request("/api/v1/recordings/rec_delete_cached", { + method: "DELETE", + }); + const stored = await recordingStore.find("rec_delete_cached"); + const [event] = await auditStore.list({ action: "recordings.delete.succeeded" }); + const before = event?.before as { recording?: RecordingSummary } | undefined; + + assert.equal(response.status, 204); + assert.equal(stored, undefined); + await assert.rejects( + readFile(cacheFilePath), + (error) => error instanceof Error && "code" in error && error.code === "ENOENT", + ); + assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.delete"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "rec_delete_cached", + type: "recording", + }); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.target.id, "rec_delete_cached"); + assert.equal(event?.details.cacheDeleted, true); + assert.equal(event?.details.cached, true); + assert.equal(before?.recording?.id, "rec_delete_cached"); +}); + +test("recording delete rejects active recordings and audits failure", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ + id: "rec_delete_active", + name: "Active Recording", + status: "recording", + }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/rec_delete_active", { + method: "DELETE", + }); + const stored = await recordingStore.find("rec_delete_active"); + const [event] = await auditStore.list({ action: "recordings.delete.failed" }); + + assert.equal(response.status, 409); + assert.equal(stored?.id, "rec_delete_active"); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.reason, "recording_active"); + assert.equal(event?.target.id, "rec_delete_active"); +}); diff --git a/apps/api/test/recording-routes-harness.ts b/apps/api/test/recording-routes-harness.ts new file mode 100644 index 00000000..7429960a --- /dev/null +++ b/apps/api/test/recording-routes-harness.ts @@ -0,0 +1,308 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import type { AuthResult } from "../src/auth-service.js"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; +import type { NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +export const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(routeRoot, "recording-cache"); +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(routeRoot, "upload-policies.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { registerRecordingRoutes } = await import("../src/recording-routes.js"); + +export { createAuditStore }; + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export interface PermissionCall { + action: string; + permission: Permission; + target?: AuditTarget; +} + +export function recordingApp({ + auditStore, + nodes, + permissionCalls, + profiles, + recordingStore, + visibleRecordingIds, +}: { + auditStore: ReturnType; + nodes: RecorderNode[]; + permissionCalls: PermissionCall[]; + profiles: RecordingProfile[]; + recordingStore: RecordingStore; + visibleRecordingIds?: string[]; +}) { + const app = new Hono(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore: memoryNodeStore(nodes), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: () => memoryNodeStore(nodes).list(), + scopedRecordings: async () => { + const recordings = await recordingStore.list(); + + return visibleRecordingIds + ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) + : recordings; + }, + settingsStore: memorySettingsStore(profiles), + }); + + return app; +} + +function requirePermission(calls: PermissionCall[]): RequirePermission { + return (permission, action, target) => { + return async (c, next) => { + calls.push({ + action, + permission, + target: target ? await target(c) : undefined, + }); + await next(); + }; + }; +} + +function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: input.actor ?? { + id: "user_recording_route", + name: "Recording Route User", + roles: ["operator"], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +function memoryNodeStore(nodes: RecorderNode[]): NodeStore { + return { + async authenticateCredential() { + return undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodes.find((node) => node.id === nodeId); + }, + async heartbeat() { + throw new Error("not implemented"); + }, + async list() { + return nodes; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + }; +} + +export function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { + return { + async create(recording) { + recordings.unshift(recording); + }, + async delete(recordingId) { + const index = recordings.findIndex((recording) => recording.id === recordingId); + + if (index < 0) { + return undefined; + } + + const [deleted] = recordings.splice(index, 1); + + return deleted; + }, + async find(recordingId) { + return recordings.find((recording) => recording.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + + if (index >= 0) { + recordings[index] = recording; + } + }, + async transition(recording, allowedFrom) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + const current = recordings[index]; + + if (!current || !allowedFrom.includes(current.status)) { + return undefined; + } + + recordings[index] = recording; + + return recording; + }, + }; +} + +function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { + return { + async assignChannelMapTemplate() { + throw new Error("not implemented"); + }, + async createChannelMapTemplate() { + throw new Error("not implemented"); + }, + async findChannelMapTemplate() { + return undefined; + }, + async findRecordingProfile(profileId) { + return profiles.find((profile) => profile.id === profileId); + }, + async findWatchdogPolicy() { + return undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listChannelMapTemplates() { + return []; + }, + async listRecordingProfiles() { + return profiles; + }, + async listWatchdogPolicies() { + return []; + }, + async rollbackChannelMapAssignment() { + return undefined; + }, + async updateChannelMapTemplate() { + return undefined; + }, + async updateRecordingProfile() { + return undefined; + }, + async updateWatchdogPolicy() { + return undefined; + }, + }; +} + +function auth(): AuthResult { + return { user: user() }; +} + +function user(): CurrentUser { + return { + email: "recording-route@example.com", + groups: [], + id: "user_recording_route", + name: "Recording Route User", + permissions: ["recording:create"], + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function recorderNode(): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Room Alpha Recorder", + hostname: "room-alpha-recorder", + id: "node_room_alpha", + interfaces: [ + { + alias: "USB Interface", + backend: "alsa", + channelCount: 2, + channels: [ + { alias: "Lectern", index: 1 }, + { alias: "Table", index: 2 }, + ], + id: "iface_usb_1", + sampleRates: [48_000], + systemName: "hw:1,0", + systemRef: "usb-1-1", + }, + ], + ipAddresses: ["10.1.2.3"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Room Alpha", + site: "Main Site", + }, + status: "online", + tags: ["voice"], + }; +} + +export function recording(input: Partial): RecordingSummary { + return { + cached: false, + durationSeconds: 120, + folder: "Meetings", + healthStatus: "healthy", + id: `rec_${randomUUID()}`, + name: "Recording", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "ad_hoc", + status: "completed", + tags: ["voice"], + ...input, + }; +} diff --git a/apps/api/test/recording-routes-metadata.test.ts b/apps/api/test/recording-routes-metadata.test.ts new file mode 100644 index 00000000..fa2541c0 --- /dev/null +++ b/apps/api/test/recording-routes-metadata.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; + +test("bulk metadata update organizes visible recordings and audits snapshots", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ + folder: "Inbox", + id: "rec_bulk_a", + name: "Bulk A", + tags: ["voice", "raw"], + }), + recording({ + folder: "Inbox", + id: "rec_bulk_b", + name: "Bulk B", + tags: ["planning"], + }), + recording({ + folder: "Inbox", + id: "rec_bulk_c", + name: "Bulk C", + tags: ["untouched"], + }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/bulk-metadata", { + body: JSON.stringify({ + addTags: ["reviewed", "voice"], + folder: "Meetings/Council", + recordingIds: ["rec_bulk_a", "rec_bulk_b", "rec_bulk_a"], + removeTags: ["raw"], + }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + const body = (await response.json()) as { + data: RecordingSummary[]; + meta: { updatedCount: number }; + }; + const updatedA = await recordingStore.find("rec_bulk_a"); + const updatedB = await recordingStore.find("rec_bulk_b"); + const untouched = await recordingStore.find("rec_bulk_c"); + const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:edit"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.metadata.bulk_update"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "recording_collection", + type: "recording_collection", + }); + assert.equal(body.meta.updatedCount, 2); + assert.deepEqual( + body.data.map((recording) => recording.id), + ["rec_bulk_a", "rec_bulk_b"], + ); + assert.equal(updatedA?.folder, "Meetings/Council"); + assert.deepEqual(updatedA?.tags, ["voice", "reviewed"]); + assert.equal(updatedB?.folder, "Meetings/Council"); + assert.deepEqual(updatedB?.tags, ["planning", "reviewed", "voice"]); + assert.equal(untouched?.folder, "Inbox"); + assert.deepEqual(untouched?.tags, ["untouched"]); + assert.deepEqual(event?.details.fields, ["folder", "addTags", "removeTags"]); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.updatedCount, 2); + assert.equal(event?.permission, "recording:edit"); + assert.equal(event?.target.type, "recording_collection"); + assert.equal( + (event?.before?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, + "Inbox", + ); + assert.equal( + (event?.after?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, + "Meetings/Council", + ); +}); + +test("bulk metadata update rejects recordings outside scoped visibility", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ folder: "Visible", id: "rec_visible", tags: ["voice"] }), + recording({ folder: "Hidden", id: "rec_hidden", tags: ["blocked"] }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + visibleRecordingIds: ["rec_visible"], + }); + + const response = await app.request("/api/v1/recordings/bulk-metadata", { + body: JSON.stringify({ + folder: "Meetings/Restricted", + recordingIds: ["rec_visible", "rec_hidden"], + }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + const visible = await recordingStore.find("rec_visible"); + const hidden = await recordingStore.find("rec_hidden"); + const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.failed" }); + + assert.equal(response.status, 404); + assert.equal(visible?.folder, "Visible"); + assert.equal(hidden?.folder, "Hidden"); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_not_visible"); + assert.deepEqual(event?.details.hiddenIds, ["rec_hidden"]); +}); + +test("ad hoc recording start audits missing dependencies", async () => { + const auditStore = createAuditStore(""); + const node = recorderNode(); + const app = recordingApp({ + auditStore, + nodes: [node], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore: memoryRecordingStore(), + }); + + const missingNode = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: "node_missing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const missingProfile = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: node.id, recordingProfileId: "profile_missing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const missingPolicy = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: node.id, uploadPolicyIds: ["policy_missing"] }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const events = await auditStore.list({ action: "recordings.start.failed" }); + + assert.equal(missingNode.status, 404); + assert.equal(missingProfile.status, 404); + assert.equal(missingPolicy.status, 404); + assert.deepEqual( + events.map((event) => event.reason), + ["upload_policy_not_found", "recording_profile_not_found", "node_not_found"], + ); +}); diff --git a/apps/api/test/recording-routes.test.ts b/apps/api/test/recording-routes.test.ts index 22e6bac9..921c355e 100644 --- a/apps/api/test/recording-routes.test.ts +++ b/apps/api/test/recording-routes.test.ts @@ -1,42 +1,15 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import type { AuthResult } from "../src/auth-service.js"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; -import type { NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; -import type { SettingsStore } from "../src/settings-store.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(routeRoot, "recording-cache"); -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(routeRoot, "upload-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; test("recording facets summarize visible library relationships", async () => { const auditStore = createAuditStore(""); @@ -346,644 +319,3 @@ test("recording list filters by profile upload policy and track group", async () ["rec_manual"], ); }); - -test("bulk metadata update organizes visible recordings and audits snapshots", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ - folder: "Inbox", - id: "rec_bulk_a", - name: "Bulk A", - tags: ["voice", "raw"], - }), - recording({ - folder: "Inbox", - id: "rec_bulk_b", - name: "Bulk B", - tags: ["planning"], - }), - recording({ - folder: "Inbox", - id: "rec_bulk_c", - name: "Bulk C", - tags: ["untouched"], - }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/bulk-metadata", { - body: JSON.stringify({ - addTags: ["reviewed", "voice"], - folder: "Meetings/Council", - recordingIds: ["rec_bulk_a", "rec_bulk_b", "rec_bulk_a"], - removeTags: ["raw"], - }), - headers: { "content-type": "application/json" }, - method: "PATCH", - }); - const body = (await response.json()) as { - data: RecordingSummary[]; - meta: { updatedCount: number }; - }; - const updatedA = await recordingStore.find("rec_bulk_a"); - const updatedB = await recordingStore.find("rec_bulk_b"); - const untouched = await recordingStore.find("rec_bulk_c"); - const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:edit"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.metadata.bulk_update"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "recording_collection", - type: "recording_collection", - }); - assert.equal(body.meta.updatedCount, 2); - assert.deepEqual( - body.data.map((recording) => recording.id), - ["rec_bulk_a", "rec_bulk_b"], - ); - assert.equal(updatedA?.folder, "Meetings/Council"); - assert.deepEqual(updatedA?.tags, ["voice", "reviewed"]); - assert.equal(updatedB?.folder, "Meetings/Council"); - assert.deepEqual(updatedB?.tags, ["planning", "reviewed", "voice"]); - assert.equal(untouched?.folder, "Inbox"); - assert.deepEqual(untouched?.tags, ["untouched"]); - assert.deepEqual(event?.details.fields, ["folder", "addTags", "removeTags"]); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.updatedCount, 2); - assert.equal(event?.permission, "recording:edit"); - assert.equal(event?.target.type, "recording_collection"); - assert.equal( - (event?.before?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, - "Inbox", - ); - assert.equal( - (event?.after?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, - "Meetings/Council", - ); -}); - -test("bulk metadata update rejects recordings outside scoped visibility", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ folder: "Visible", id: "rec_visible", tags: ["voice"] }), - recording({ folder: "Hidden", id: "rec_hidden", tags: ["blocked"] }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - visibleRecordingIds: ["rec_visible"], - }); - - const response = await app.request("/api/v1/recordings/bulk-metadata", { - body: JSON.stringify({ - folder: "Meetings/Restricted", - recordingIds: ["rec_visible", "rec_hidden"], - }), - headers: { "content-type": "application/json" }, - method: "PATCH", - }); - const visible = await recordingStore.find("rec_visible"); - const hidden = await recordingStore.find("rec_hidden"); - const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.failed" }); - - assert.equal(response.status, 404); - assert.equal(visible?.folder, "Visible"); - assert.equal(hidden?.folder, "Hidden"); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_not_visible"); - assert.deepEqual(event?.details.hiddenIds, ["rec_hidden"]); -}); - -test("bulk recording delete removes terminal recordings and audits one snapshot", async () => { - const auditStore = createAuditStore(""); - const cachePath = "ad-hoc/rec_bulk_delete_cached.mp3"; - const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); - const recordingStore = memoryRecordingStore([ - recording({ - cached: true, - cachePath, - id: "rec_bulk_delete_cached", - status: "cached", - }), - recording({ id: "rec_bulk_delete_terminal", status: "completed" }), - recording({ id: "rec_bulk_delete_keep", status: "completed" }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - await mkdir(path.dirname(cacheFilePath), { recursive: true }); - await writeFile(cacheFilePath, Buffer.from("cached audio")); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: [ - "rec_bulk_delete_cached", - "rec_bulk_delete_terminal", - "rec_bulk_delete_cached", - ], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingSummary[]; - meta: { cacheDeletedCount: number; deletedCount: number }; - }; - const deletedCached = await recordingStore.find("rec_bulk_delete_cached"); - const deletedTerminal = await recordingStore.find("rec_bulk_delete_terminal"); - const kept = await recordingStore.find("rec_bulk_delete_keep"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.succeeded" }); - const before = event?.before as { recordings?: RecordingSummary[] } | undefined; - - assert.equal(response.status, 200); - assert.equal(deletedCached, undefined); - assert.equal(deletedTerminal, undefined); - assert.equal(kept?.id, "rec_bulk_delete_keep"); - await assert.rejects( - readFile(cacheFilePath), - (error) => error instanceof Error && "code" in error && error.code === "ENOENT", - ); - assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.bulk_delete"); - assert.equal(body.meta.deletedCount, 2); - assert.equal(body.meta.cacheDeletedCount, 1); - assert.deepEqual( - body.data.map((recording) => recording.id), - ["rec_bulk_delete_cached", "rec_bulk_delete_terminal"], - ); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.target.type, "recording_collection"); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.deletedCount, 2); - assert.equal(before?.recordings?.[0]?.id, "rec_bulk_delete_cached"); -}); - -test("bulk recording delete rejects hidden recordings", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ id: "rec_bulk_delete_visible", status: "completed" }), - recording({ id: "rec_bulk_delete_hidden", status: "completed" }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - visibleRecordingIds: ["rec_bulk_delete_visible"], - }); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: ["rec_bulk_delete_visible", "rec_bulk_delete_hidden"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const visible = await recordingStore.find("rec_bulk_delete_visible"); - const hidden = await recordingStore.find("rec_bulk_delete_hidden"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); - - assert.equal(response.status, 404); - assert.equal(visible?.id, "rec_bulk_delete_visible"); - assert.equal(hidden?.id, "rec_bulk_delete_hidden"); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_not_visible"); - assert.deepEqual(event?.details.hiddenIds, ["rec_bulk_delete_hidden"]); -}); - -test("bulk recording delete rejects active recordings before deleting anything", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ id: "rec_bulk_delete_ready", status: "completed" }), - recording({ id: "rec_bulk_delete_active", status: "recording" }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: ["rec_bulk_delete_ready", "rec_bulk_delete_active"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const ready = await recordingStore.find("rec_bulk_delete_ready"); - const active = await recordingStore.find("rec_bulk_delete_active"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); - - assert.equal(response.status, 409); - assert.equal(ready?.id, "rec_bulk_delete_ready"); - assert.equal(active?.id, "rec_bulk_delete_active"); - assert.equal(event?.reason, "recording_active"); - assert.deepEqual(event?.details.activeIds, ["rec_bulk_delete_active"]); -}); - -test("recording delete removes terminal metadata cached file and audits snapshot", async () => { - const auditStore = createAuditStore(""); - const cachePath = "ad-hoc/rec_delete_cached.mp3"; - const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); - const recordingStore = memoryRecordingStore([ - recording({ - cached: true, - cachePath, - id: "rec_delete_cached", - name: "Delete Cached Recording", - status: "cached", - }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - await mkdir(path.dirname(cacheFilePath), { recursive: true }); - await writeFile(cacheFilePath, Buffer.from("cached audio")); - - const response = await app.request("/api/v1/recordings/rec_delete_cached", { - method: "DELETE", - }); - const stored = await recordingStore.find("rec_delete_cached"); - const [event] = await auditStore.list({ action: "recordings.delete.succeeded" }); - const before = event?.before as { recording?: RecordingSummary } | undefined; - - assert.equal(response.status, 204); - assert.equal(stored, undefined); - await assert.rejects( - readFile(cacheFilePath), - (error) => error instanceof Error && "code" in error && error.code === "ENOENT", - ); - assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.delete"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "rec_delete_cached", - type: "recording", - }); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.target.id, "rec_delete_cached"); - assert.equal(event?.details.cacheDeleted, true); - assert.equal(event?.details.cached, true); - assert.equal(before?.recording?.id, "rec_delete_cached"); -}); - -test("recording delete rejects active recordings and audits failure", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ - id: "rec_delete_active", - name: "Active Recording", - status: "recording", - }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/rec_delete_active", { - method: "DELETE", - }); - const stored = await recordingStore.find("rec_delete_active"); - const [event] = await auditStore.list({ action: "recordings.delete.failed" }); - - assert.equal(response.status, 409); - assert.equal(stored?.id, "rec_delete_active"); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.reason, "recording_active"); - assert.equal(event?.target.id, "rec_delete_active"); -}); - -test("ad hoc recording start audits missing dependencies", async () => { - const auditStore = createAuditStore(""); - const node = recorderNode(); - const app = recordingApp({ - auditStore, - nodes: [node], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore: memoryRecordingStore(), - }); - - const missingNode = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: "node_missing" }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const missingProfile = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: node.id, recordingProfileId: "profile_missing" }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const missingPolicy = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: node.id, uploadPolicyIds: ["policy_missing"] }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const events = await auditStore.list({ action: "recordings.start.failed" }); - - assert.equal(missingNode.status, 404); - assert.equal(missingProfile.status, 404); - assert.equal(missingPolicy.status, 404); - assert.deepEqual( - events.map((event) => event.reason), - ["upload_policy_not_found", "recording_profile_not_found", "node_not_found"], - ); -}); - -interface PermissionCall { - action: string; - permission: Permission; - target?: AuditTarget; -} - -function recordingApp({ - auditStore, - nodes, - permissionCalls, - profiles, - recordingStore, - visibleRecordingIds, -}: { - auditStore: ReturnType; - nodes: RecorderNode[]; - permissionCalls: PermissionCall[]; - profiles: RecordingProfile[]; - recordingStore: RecordingStore; - visibleRecordingIds?: string[]; -}) { - const app = new Hono(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore: memoryNodeStore(nodes), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: () => memoryNodeStore(nodes).list(), - scopedRecordings: async () => { - const recordings = await recordingStore.list(); - - return visibleRecordingIds - ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) - : recordings; - }, - settingsStore: memorySettingsStore(profiles), - }); - - return app; -} - -function requirePermission(calls: PermissionCall[]): RequirePermission { - return (permission, action, target) => { - return async (c, next) => { - calls.push({ - action, - permission, - target: target ? await target(c) : undefined, - }); - await next(); - }; - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: input.actor ?? { - id: "user_recording_route", - name: "Recording Route User", - roles: ["operator"], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memoryNodeStore(nodes: RecorderNode[]): NodeStore { - return { - async authenticateCredential() { - return undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodes.find((node) => node.id === nodeId); - }, - async heartbeat() { - throw new Error("not implemented"); - }, - async list() { - return nodes; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - }; -} - -function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { - return { - async create(recording) { - recordings.unshift(recording); - }, - async delete(recordingId) { - const index = recordings.findIndex((recording) => recording.id === recordingId); - - if (index < 0) { - return undefined; - } - - const [deleted] = recordings.splice(index, 1); - - return deleted; - }, - async find(recordingId) { - return recordings.find((recording) => recording.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - - if (index >= 0) { - recordings[index] = recording; - } - }, - async transition(recording, allowedFrom) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - const current = recordings[index]; - - if (!current || !allowedFrom.includes(current.status)) { - return undefined; - } - - recordings[index] = recording; - - return recording; - }, - }; -} - -function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { - return { - async assignChannelMapTemplate() { - throw new Error("not implemented"); - }, - async createChannelMapTemplate() { - throw new Error("not implemented"); - }, - async findChannelMapTemplate() { - return undefined; - }, - async findRecordingProfile(profileId) { - return profiles.find((profile) => profile.id === profileId); - }, - async findWatchdogPolicy() { - return undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listChannelMapTemplates() { - return []; - }, - async listRecordingProfiles() { - return profiles; - }, - async listWatchdogPolicies() { - return []; - }, - async rollbackChannelMapAssignment() { - return undefined; - }, - async updateChannelMapTemplate() { - return undefined; - }, - async updateRecordingProfile() { - return undefined; - }, - async updateWatchdogPolicy() { - return undefined; - }, - }; -} - -function auth(): AuthResult { - return { user: user() }; -} - -function user(): CurrentUser { - return { - email: "recording-route@example.com", - groups: [], - id: "user_recording_route", - name: "Recording Route User", - permissions: ["recording:create"], - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function recorderNode(): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Room Alpha Recorder", - hostname: "room-alpha-recorder", - id: "node_room_alpha", - interfaces: [ - { - alias: "USB Interface", - backend: "alsa", - channelCount: 2, - channels: [ - { alias: "Lectern", index: 1 }, - { alias: "Table", index: 2 }, - ], - id: "iface_usb_1", - sampleRates: [48_000], - systemName: "hw:1,0", - systemRef: "usb-1-1", - }, - ], - ipAddresses: ["10.1.2.3"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Room Alpha", - site: "Main Site", - }, - status: "online", - tags: ["voice"], - }; -} - -function recording(input: Partial): RecordingSummary { - return { - cached: false, - durationSeconds: 120, - folder: "Meetings", - healthStatus: "healthy", - id: `rec_${randomUUID()}`, - name: "Recording", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "ad_hoc", - status: "completed", - tags: ["voice"], - ...input, - }; -} diff --git a/apps/api/test/schedule-routes-actions.test.ts b/apps/api/test/schedule-routes-actions.test.ts new file mode 100644 index 00000000..c76caa4b --- /dev/null +++ b/apps/api/test/schedule-routes-actions.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + type ScheduleActionsResponse, + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + schedule, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; + +test("schedule action summary returns scoped readiness links and node context", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visible = schedule({ + id: `sched_actions_visible_${randomUUID()}`, + name: "Visible Actions", + nodeId: "node_schedule_actions", + }); + const hidden = schedule({ id: `sched_actions_hidden_${randomUUID()}` }); + const store = scheduleStore([visible, hidden]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node({ id: visible.nodeId })]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node({ id: visible.nodeId })], + scopedSchedules: async () => [visible], + settingsStore: createSettingsStore(), + }); + + const visibleResponse = await app.request(`/api/v1/schedules/${visible.id}/actions`); + const hiddenResponse = await app.request(`/api/v1/schedules/${hidden.id}/actions`); + const body = (await visibleResponse.json()) as ScheduleActionsResponse; + const [succeededEvent] = await auditStore.list({ + action: "schedules.actions.read.succeeded", + }); + const [failedEvent] = await auditStore.list({ action: "schedules.actions.read.failed" }); + + assert.equal(visibleResponse.status, 200); + assert.equal(hiddenResponse.status, 404); + assert.equal(body.data.schedule.id, visible.id); + assert.equal(body.data.node?.id, visible.nodeId); + assert.equal(body.data.actions.edit.enabled, true); + assert.equal(body.data.actions.delete.enabled, true); + assert.equal(body.data.actions.runNow.enabled, true); + assert.equal(body.data.actions.skipNext.enabled, true); + assert.equal(body.data.actions.occurrences.enabled, true); + assert.equal(body.data.links.runNow, `/api/v1/schedules/${visible.id}/run-now`); + assert.equal(body.data.links.occurrences, `/api/v1/schedules/${visible.id}/occurrences`); + assert.equal(succeededEvent?.outcome, "succeeded"); + assert.equal(succeededEvent?.permission, "schedule:read"); + assert.equal(succeededEvent?.target.id, visible.id); + assert.equal(succeededEvent?.target.name, visible.name); + assert.equal(succeededEvent?.details.nodeAvailable, true); + assert.equal(succeededEvent?.details.scheduleEnabled, true); + assert.equal(succeededEvent?.details.visibleActionCount, 5); + assert.equal(failedEvent?.outcome, "failed"); + assert.equal(failedEvent?.permission, "schedule:read"); + assert.equal(failedEvent?.reason, "schedule_not_found"); + assert.equal(failedEvent?.target.id, hidden.id); +}); + +test("schedule action summary reports lifecycle blockers for disabled schedules", async () => { + const app = new Hono(); + const currentUser = user(["schedule:read", "schedule:manage"]); + const disabled = schedule({ + enabled: false, + id: `sched_actions_disabled_${randomUUID()}`, + name: "Disabled Actions", + }); + const store = scheduleStore([disabled]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(createAuditStore("")), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [disabled], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${disabled.id}/actions`); + const body = (await response.json()) as ScheduleActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.actions.runNow.enabled, false); + assert.equal(body.data.actions.runNow.reason, "schedule_disabled"); + assert.equal(body.data.actions.skipNext.enabled, false); + assert.equal(body.data.actions.skipNext.reason, "schedule_disabled"); +}); + +test("schedule action summary hides out-of-scope node context and readiness", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visibleNode = node({ id: "node_schedule_action_visible" }); + const hiddenNode = node({ id: "node_schedule_action_hidden" }); + const scheduleOnHiddenNode = schedule({ + id: "sched_action_hidden_node", + name: "Hidden Node Action Summary", + nodeId: hiddenNode.id, + }); + const store = scheduleStore([scheduleOnHiddenNode]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([visibleNode, hiddenNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [visibleNode], + scopedSchedules: async () => [scheduleOnHiddenNode], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${scheduleOnHiddenNode.id}/actions`); + const body = (await response.json()) as ScheduleActionsResponse; + const [event] = await auditStore.list({ action: "schedules.actions.read.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(body.data.schedule.id, scheduleOnHiddenNode.id); + assert.equal(body.data.node, undefined); + assert.equal(body.data.actions.runNow.enabled, false); + assert.equal(body.data.actions.runNow.reason, "schedule_node_not_found"); + assert.equal(body.data.actions.skipNext.enabled, true); + assert.equal(event?.details.nodeAvailable, false); + assert.equal(event?.target.id, scheduleOnHiddenNode.id); +}); diff --git a/apps/api/test/schedule-routes-harness.ts b/apps/api/test/schedule-routes-harness.ts new file mode 100644 index 00000000..dabd4784 --- /dev/null +++ b/apps/api/test/schedule-routes-harness.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + RecordingSummary, + ScheduleSummary, +} from "@rakkr/shared"; +import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; +import type { ScheduleStore } from "../src/schedule-store.js"; + +const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-schedule-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "recording-jobs.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { createNodeStore } = await import("../src/node-store.js"); +const { registerScheduleRoutes } = await import("../src/schedule-routes.js"); +const { createSettingsStore } = await import("../src/settings-store.js"); + +export { createAuditStore, createNodeStore, registerScheduleRoutes, createSettingsStore }; + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export function requestJson( + app: Hono, + path: string, + method: "PATCH" | "POST", + body: Record, +) { + return app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, + }); +} + +export async function scheduleList(app: Hono, query = "") { + const response = await app.request(`/api/v1/schedules${query}`); + const body = (await response.json()) as { data: ScheduleSummary[] }; + + assert.equal(response.status, 200); + + return body.data; +} + +export interface ScheduleActionsResponse { + data: { + actions: Record; + links: Record; + node?: RecorderNode; + schedule: ScheduleSummary; + }; +} + +export function allowPermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function denyMissingPermission( + auditStore: ReturnType, + currentUser: CurrentUser, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function scheduleStore(schedules: ScheduleSummary[]): ScheduleStore { + return { + async create(schedule) { + schedules.unshift(schedule); + + return schedule; + }, + async delete(scheduleId) { + const index = schedules.findIndex((candidate) => candidate.id === scheduleId); + const [deleted] = index >= 0 ? schedules.splice(index, 1) : []; + + return deleted; + }, + async find(scheduleId) { + return schedules.find((candidate) => candidate.id === scheduleId); + }, + async list() { + return schedules; + }, + async update(scheduleId, update) { + const index = schedules.findIndex((candidate) => candidate.id === scheduleId); + + if (index < 0) { + return undefined; + } + + schedules[index] = { ...schedules[index], ...update }; + + return schedules[index]; + }, + }; +} + +export function recordingStore() { + const recordings: RecordingSummary[] = []; + + return { + async create(recording: RecordingSummary) { + recordings.unshift(recording); + }, + async delete(recordingId: string) { + const index = recordings.findIndex((candidate) => candidate.id === recordingId); + const [deleted] = index >= 0 ? recordings.splice(index, 1) : []; + + return deleted; + }, + async find(recordingId: string) { + return recordings.find((candidate) => candidate.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording: RecordingSummary) { + recordings.unshift(recording); + }, + }; +} + +export function user(permissions: Permission[] = ["schedule:read"]): CurrentUser { + return { + email: "schedule-viewer@example.com", + groups: [], + id: "user_schedule_viewer_test", + name: "Schedule Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function node(input: Partial = {}): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Schedule Node", + hostname: "schedule-node", + id: "node_schedule_test", + interfaces: [], + ipAddresses: ["10.0.0.60"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Council Room", + site: "Main Site", + }, + status: "online", + tags: ["voice"], + ...input, + }; +} + +export function schedule(input: Partial = {}): ScheduleSummary { + return { + assignedGroupIds: [], + assignedUserIds: [], + enabled: true, + folderTemplate: "meetings/{{date}}", + id: "sched_route_test", + name: "Council Meeting", + nextRunAt: "2026-06-18T09:00:00.000Z", + nodeId: node().id, + recurrence: { mode: "manual" }, + recordingProfileId: "voice-mp3-vbr", + retentionPolicyId: "retention-keep-controller-cache", + room: "Council Room", + tags: ["council"], + timezone: "UTC", + titleTemplate: "{{date}} Council Meeting", + uploadPolicyIds: ["upload-policy-stub"], + watchdogPolicyId: "scheduled-voice-watchdog", + ...input, + }; +} diff --git a/apps/api/test/schedule-routes-lifecycle.test.ts b/apps/api/test/schedule-routes-lifecycle.test.ts new file mode 100644 index 00000000..6a95355c --- /dev/null +++ b/apps/api/test/schedule-routes-lifecycle.test.ts @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { RecordingSummary, ScheduleSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + requestJson, + schedule, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; + +test("schedule occurrence and lifecycle routes only operate on scoped schedules", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visible = schedule({ id: "sched_visible_lifecycle", name: "Visible Lifecycle" }); + const hidden = schedule({ id: "sched_hidden_lifecycle", name: "Hidden Lifecycle" }); + const store = scheduleStore([visible, hidden]); + const recordings = recordingStore(); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [visible], + settingsStore: createSettingsStore(), + }); + + const occurrences = await app.request(`/api/v1/schedules/${hidden.id}/occurrences`); + const update = await requestJson(app, `/api/v1/schedules/${hidden.id}`, "PATCH", { + name: "Hidden Mutated", + }); + const runNow = await app.request(`/api/v1/schedules/${hidden.id}/run-now`, { method: "POST" }); + const skipNext = await app.request(`/api/v1/schedules/${hidden.id}/skip-next`, { + method: "POST", + }); + const deleted = await app.request(`/api/v1/schedules/${hidden.id}`, { method: "DELETE" }); + const stillHidden = await store.find(hidden.id); + const recordingList = await recordings.list(); + const failedEvents = await auditStore.list({ outcome: "failed" }); + const readFailures = failedEvents.filter((event) => event.permission === "schedule:read"); + const manageFailures = failedEvents.filter((event) => event.permission === "schedule:manage"); + + assert.deepEqual( + [occurrences.status, update.status, runNow.status, skipNext.status, deleted.status], + [404, 404, 404, 404, 404], + ); + assert.equal(stillHidden?.name, hidden.name); + assert.deepEqual(recordingList, []); + assert.deepEqual( + readFailures.map((event) => `${event.action}:${event.reason}`), + ["schedules.occurrences.read.failed:schedule_not_found"], + ); + assert.deepEqual(manageFailures.map((event) => `${event.action}:${event.reason}`).sort(), [ + "schedules.delete.failed:schedule_not_found", + "schedules.run_now.failed:schedule_not_found", + "schedules.skip_next.failed:schedule_not_found", + "schedules.update.failed:schedule_not_found", + ]); +}); + +test("disabled schedule run-now is rejected and audited", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:manage", "schedule:read"]); + const disabled = schedule({ + enabled: false, + id: `sched_disabled_run_${randomUUID()}`, + nodeId: node().id, + }); + const store = scheduleStore([disabled]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [disabled], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${disabled.id}/run-now`, { + method: "POST", + }); + const [event] = await auditStore.list({ action: "schedules.run_now.failed" }); + + assert.equal(response.status, 409); + assert.equal(event?.reason, "schedule_disabled"); + assert.equal(event?.target.id, disabled.id); +}); + +test("schedule work routes only operate on scoped visible nodes", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:manage", "schedule:read"]); + const visibleNode = node({ id: "node_schedule_visible" }); + const hiddenNode = node({ id: "node_schedule_hidden" }); + const visible = schedule({ + id: "sched_visible_node_scope", + nodeId: visibleNode.id, + }); + const hiddenNodeSchedule = schedule({ + id: "sched_hidden_node_scope", + nodeId: hiddenNode.id, + }); + const store = scheduleStore([visible, hiddenNodeSchedule]); + const recordings = recordingStore(); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([visibleNode, hiddenNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [visibleNode], + scopedSchedules: () => store.list(), + settingsStore: createSettingsStore(), + }); + + const createHidden = await requestJson(app, "/api/v1/schedules", "POST", { + enabled: true, + folderTemplate: "Hidden/{{date}}", + name: "Hidden Node Schedule", + nodeId: hiddenNode.id, + recordingProfileId: "voice-mp3-vbr", + room: "Hidden Room", + timezone: "UTC", + titleTemplate: "{{date}} Hidden Node Schedule", + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const updateHidden = await requestJson(app, `/api/v1/schedules/${visible.id}`, "PATCH", { + nodeId: hiddenNode.id, + }); + const runHidden = await app.request(`/api/v1/schedules/${hiddenNodeSchedule.id}/run-now`, { + method: "POST", + }); + const failedEvents = await auditStore.list({ + outcome: "failed", + permission: "schedule:manage", + }); + + assert.equal(createHidden.status, 409); + assert.equal(updateHidden.status, 409); + assert.equal(runHidden.status, 409); + assert.equal((await store.find(visible.id))?.nodeId, visibleNode.id); + assert.equal( + (await store.list()).some((candidate) => candidate.name === "Hidden Node Schedule"), + false, + ); + assert.equal((await recordings.list()).length, 0); + assert.deepEqual(failedEvents.map((event) => `${event.action}:${event.reason}`).sort(), [ + "schedules.create.failed:schedule_node_not_found", + "schedules.run_now.failed:schedule_node_not_found", + "schedules.update.failed:schedule_node_not_found", + ]); + assert.equal( + failedEvents.find((event) => event.action === "schedules.run_now.failed")?.target.id, + hiddenNodeSchedule.id, + ); +}); + +test("schedule routes create update run-now and skip-next with audit events", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["recording:read", "schedule:read", "schedule:manage"]); + const routeInterfaceId = "iface_route_jack"; + const routeNode = node({ + id: `node_schedule_ops_${randomUUID()}`, + interfaces: [ + { + alias: "JACK Route", + backend: "jack", + channelCount: 2, + channels: [ + { alias: "Left", index: 1 }, + { alias: "Right", index: 2 }, + ], + id: routeInterfaceId, + sampleRates: [48_000], + systemName: "jack:route", + systemRef: "jack:route", + }, + ], + }); + const schedules: ScheduleSummary[] = []; + const store = scheduleStore(schedules); + const recordings = recordingStore(); + const scheduleId = `sched_route_ops_${randomUUID()}`; + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([routeNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [routeNode], + scopedSchedules: () => store.list(), + settingsStore: createSettingsStore(), + }); + + const invalidInterface = await requestJson(app, "/api/v1/schedules", "POST", { + enabled: true, + captureInterfaceId: "missing_interface", + folderTemplate: "Meetings/{{date}}/{{schedule.name}}", + name: "Invalid Interface", + nodeId: routeNode.id, + recordingProfileId: "voice-mp3-vbr", + room: "Council Room", + timezone: "UTC", + titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const created = await requestJson(app, "/api/v1/schedules", "POST", { + captureBackend: "jack", + captureInterfaceId: routeInterfaceId, + enabled: true, + folderTemplate: "Meetings/{{date}}/{{schedule.name}}", + id: scheduleId, + name: "Council Route Test", + nodeId: routeNode.id, + recurrence: { + endTime: "10:00", + interval: 1, + mode: "daily", + startEarlySeconds: 300, + startTime: "09:00", + stopLateSeconds: 120, + }, + recordingProfileId: "voice-mp3-vbr", + retentionPolicyId: "retention-keep-controller-cache", + room: "Council Room", + tags: ["voice", "route", "voice"], + timezone: "UTC", + titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", + uploadPolicyIds: ["upload-policy-stub"], + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const createdBody = (await created.json()) as { data: ScheduleSummary }; + const updated = await requestJson(app, `/api/v1/schedules/${scheduleId}`, "PATCH", { + name: "Council Route Test Updated", + tags: ["updated", "voice", "updated"], + }); + const updatedBody = (await updated.json()) as { data: ScheduleSummary }; + const occurrences = await app.request(`/api/v1/schedules/${scheduleId}/occurrences?limit=2`); + const runNow = await app.request(`/api/v1/schedules/${scheduleId}/run-now`, { method: "POST" }); + const runNowBody = (await runNow.json()) as { + data: RecordingSummary; + job: { + command: { captureBackend?: string; captureDevice: string; captureInterfaceId?: string }; + recordingId: string; + }; + segments: Array<{ recordingId: string }>; + }; + const beforeSkip = await store.find(scheduleId); + const skipped = await app.request(`/api/v1/schedules/${scheduleId}/skip-next`, { + method: "POST", + }); + const skippedBody = (await skipped.json()) as { data: ScheduleSummary }; + const succeededAudits = await auditStore.list({ + outcome: "succeeded", + permission: "schedule:manage", + }); + const runNowAudit = succeededAudits.find( + (event) => event.action === "schedules.run_now.succeeded", + ); + const [occurrencesAudit] = await auditStore.list({ + action: "schedules.occurrences.read.succeeded", + }); + + assert.equal(invalidInterface.status, 409); + assert.equal(created.status, 201); + assert.equal(updated.status, 200); + assert.equal(occurrences.status, 200); + assert.equal(runNow.status, 202); + assert.equal(skipped.status, 200); + assert.equal(createdBody.data.id, scheduleId); + assert.equal(createdBody.data.captureBackend, "jack"); + assert.equal(createdBody.data.captureInterfaceId, routeInterfaceId); + assert.deepEqual(createdBody.data.tags, ["voice", "route"]); + assert.equal(updatedBody.data.name, "Council Route Test Updated"); + assert.deepEqual(updatedBody.data.tags, ["updated", "voice"]); + assert.equal(runNowBody.data.scheduleId, scheduleId); + assert.equal(runNowBody.data.retentionPolicyId, "retention-keep-controller-cache"); + assert.equal(runNowBody.job.command.captureBackend, "jack"); + assert.equal(runNowBody.job.command.captureDevice, "jack:route"); + assert.equal(runNowBody.job.command.captureInterfaceId, routeInterfaceId); + assert.equal(runNowBody.job.recordingId, runNowBody.data.id); + assert.deepEqual( + runNowBody.segments.map((segment) => segment.recordingId), + [runNowBody.data.id], + ); + assert.equal((await recordings.list())[0]?.source, "schedule"); + assert.notEqual(skippedBody.data.nextRunAt, beforeSkip?.nextRunAt); + assert.ok( + skippedBody.data.recurrence.exceptions?.some((exception) => exception.action === "skip"), + ); + assert.deepEqual(succeededAudits.map((event) => event.action).sort(), [ + "schedules.create.succeeded", + "schedules.run_now.succeeded", + "schedules.skip_next.succeeded", + "schedules.update.succeeded", + ]); + assert.equal(runNowAudit?.correlationIds?.scheduleId, scheduleId); + assert.equal(runNowAudit?.details.captureBackend, "jack"); + assert.equal(runNowAudit?.details.captureInterfaceId, routeInterfaceId); + assert.equal(runNowAudit?.after?.recordingId, runNowBody.data.id); + assert.equal(occurrencesAudit?.permission, "schedule:read"); + assert.equal(occurrencesAudit?.target.id, scheduleId); + assert.equal(occurrencesAudit?.details.requestedLimit, 2); + assert.equal(occurrencesAudit?.details.occurrenceCount, 2); +}); diff --git a/apps/api/test/schedule-routes.test.ts b/apps/api/test/schedule-routes.test.ts index 25c8f958..7f275fa3 100644 --- a/apps/api/test/schedule-routes.test.ts +++ b/apps/api/test/schedule-routes.test.ts @@ -1,33 +1,24 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - RecordingSummary, - ScheduleSummary, -} from "@rakkr/shared"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import type { ScheduleStore } from "../src/schedule-store.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-schedule-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "recording-jobs.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createNodeStore } = await import("../src/node-store.js"); -const { registerScheduleRoutes } = await import("../src/schedule-routes.js"); -const { createSettingsStore } = await import("../src/settings-store.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { ScheduleSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + denyMissingPermission, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + requestJson, + schedule, + scheduleList, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; test("schedule routes deny users without required permissions", async () => { const auditStore = createAuditStore(""); @@ -337,659 +328,3 @@ test("schedule detail route returns scoped schedules only", async () => { "sched_missing:schedule_not_found", ]); }); - -test("schedule occurrence and lifecycle routes only operate on scoped schedules", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visible = schedule({ id: "sched_visible_lifecycle", name: "Visible Lifecycle" }); - const hidden = schedule({ id: "sched_hidden_lifecycle", name: "Hidden Lifecycle" }); - const store = scheduleStore([visible, hidden]); - const recordings = recordingStore(); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [visible], - settingsStore: createSettingsStore(), - }); - - const occurrences = await app.request(`/api/v1/schedules/${hidden.id}/occurrences`); - const update = await requestJson(app, `/api/v1/schedules/${hidden.id}`, "PATCH", { - name: "Hidden Mutated", - }); - const runNow = await app.request(`/api/v1/schedules/${hidden.id}/run-now`, { method: "POST" }); - const skipNext = await app.request(`/api/v1/schedules/${hidden.id}/skip-next`, { - method: "POST", - }); - const deleted = await app.request(`/api/v1/schedules/${hidden.id}`, { method: "DELETE" }); - const stillHidden = await store.find(hidden.id); - const recordingList = await recordings.list(); - const failedEvents = await auditStore.list({ outcome: "failed" }); - const readFailures = failedEvents.filter((event) => event.permission === "schedule:read"); - const manageFailures = failedEvents.filter((event) => event.permission === "schedule:manage"); - - assert.deepEqual( - [occurrences.status, update.status, runNow.status, skipNext.status, deleted.status], - [404, 404, 404, 404, 404], - ); - assert.equal(stillHidden?.name, hidden.name); - assert.deepEqual(recordingList, []); - assert.deepEqual( - readFailures.map((event) => `${event.action}:${event.reason}`), - ["schedules.occurrences.read.failed:schedule_not_found"], - ); - assert.deepEqual(manageFailures.map((event) => `${event.action}:${event.reason}`).sort(), [ - "schedules.delete.failed:schedule_not_found", - "schedules.run_now.failed:schedule_not_found", - "schedules.skip_next.failed:schedule_not_found", - "schedules.update.failed:schedule_not_found", - ]); -}); - -test("schedule action summary returns scoped readiness links and node context", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visible = schedule({ - id: `sched_actions_visible_${randomUUID()}`, - name: "Visible Actions", - nodeId: "node_schedule_actions", - }); - const hidden = schedule({ id: `sched_actions_hidden_${randomUUID()}` }); - const store = scheduleStore([visible, hidden]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node({ id: visible.nodeId })]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node({ id: visible.nodeId })], - scopedSchedules: async () => [visible], - settingsStore: createSettingsStore(), - }); - - const visibleResponse = await app.request(`/api/v1/schedules/${visible.id}/actions`); - const hiddenResponse = await app.request(`/api/v1/schedules/${hidden.id}/actions`); - const body = (await visibleResponse.json()) as ScheduleActionsResponse; - const [succeededEvent] = await auditStore.list({ - action: "schedules.actions.read.succeeded", - }); - const [failedEvent] = await auditStore.list({ action: "schedules.actions.read.failed" }); - - assert.equal(visibleResponse.status, 200); - assert.equal(hiddenResponse.status, 404); - assert.equal(body.data.schedule.id, visible.id); - assert.equal(body.data.node?.id, visible.nodeId); - assert.equal(body.data.actions.edit.enabled, true); - assert.equal(body.data.actions.delete.enabled, true); - assert.equal(body.data.actions.runNow.enabled, true); - assert.equal(body.data.actions.skipNext.enabled, true); - assert.equal(body.data.actions.occurrences.enabled, true); - assert.equal(body.data.links.runNow, `/api/v1/schedules/${visible.id}/run-now`); - assert.equal(body.data.links.occurrences, `/api/v1/schedules/${visible.id}/occurrences`); - assert.equal(succeededEvent?.outcome, "succeeded"); - assert.equal(succeededEvent?.permission, "schedule:read"); - assert.equal(succeededEvent?.target.id, visible.id); - assert.equal(succeededEvent?.target.name, visible.name); - assert.equal(succeededEvent?.details.nodeAvailable, true); - assert.equal(succeededEvent?.details.scheduleEnabled, true); - assert.equal(succeededEvent?.details.visibleActionCount, 5); - assert.equal(failedEvent?.outcome, "failed"); - assert.equal(failedEvent?.permission, "schedule:read"); - assert.equal(failedEvent?.reason, "schedule_not_found"); - assert.equal(failedEvent?.target.id, hidden.id); -}); - -test("schedule action summary reports lifecycle blockers for disabled schedules", async () => { - const app = new Hono(); - const currentUser = user(["schedule:read", "schedule:manage"]); - const disabled = schedule({ - enabled: false, - id: `sched_actions_disabled_${randomUUID()}`, - name: "Disabled Actions", - }); - const store = scheduleStore([disabled]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(createAuditStore("")), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [disabled], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${disabled.id}/actions`); - const body = (await response.json()) as ScheduleActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.actions.runNow.enabled, false); - assert.equal(body.data.actions.runNow.reason, "schedule_disabled"); - assert.equal(body.data.actions.skipNext.enabled, false); - assert.equal(body.data.actions.skipNext.reason, "schedule_disabled"); -}); - -test("schedule action summary hides out-of-scope node context and readiness", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visibleNode = node({ id: "node_schedule_action_visible" }); - const hiddenNode = node({ id: "node_schedule_action_hidden" }); - const scheduleOnHiddenNode = schedule({ - id: "sched_action_hidden_node", - name: "Hidden Node Action Summary", - nodeId: hiddenNode.id, - }); - const store = scheduleStore([scheduleOnHiddenNode]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([visibleNode, hiddenNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [visibleNode], - scopedSchedules: async () => [scheduleOnHiddenNode], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${scheduleOnHiddenNode.id}/actions`); - const body = (await response.json()) as ScheduleActionsResponse; - const [event] = await auditStore.list({ action: "schedules.actions.read.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(body.data.schedule.id, scheduleOnHiddenNode.id); - assert.equal(body.data.node, undefined); - assert.equal(body.data.actions.runNow.enabled, false); - assert.equal(body.data.actions.runNow.reason, "schedule_node_not_found"); - assert.equal(body.data.actions.skipNext.enabled, true); - assert.equal(event?.details.nodeAvailable, false); - assert.equal(event?.target.id, scheduleOnHiddenNode.id); -}); - -test("disabled schedule run-now is rejected and audited", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:manage", "schedule:read"]); - const disabled = schedule({ - enabled: false, - id: `sched_disabled_run_${randomUUID()}`, - nodeId: node().id, - }); - const store = scheduleStore([disabled]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [disabled], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${disabled.id}/run-now`, { - method: "POST", - }); - const [event] = await auditStore.list({ action: "schedules.run_now.failed" }); - - assert.equal(response.status, 409); - assert.equal(event?.reason, "schedule_disabled"); - assert.equal(event?.target.id, disabled.id); -}); - -test("schedule work routes only operate on scoped visible nodes", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:manage", "schedule:read"]); - const visibleNode = node({ id: "node_schedule_visible" }); - const hiddenNode = node({ id: "node_schedule_hidden" }); - const visible = schedule({ - id: "sched_visible_node_scope", - nodeId: visibleNode.id, - }); - const hiddenNodeSchedule = schedule({ - id: "sched_hidden_node_scope", - nodeId: hiddenNode.id, - }); - const store = scheduleStore([visible, hiddenNodeSchedule]); - const recordings = recordingStore(); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([visibleNode, hiddenNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [visibleNode], - scopedSchedules: () => store.list(), - settingsStore: createSettingsStore(), - }); - - const createHidden = await requestJson(app, "/api/v1/schedules", "POST", { - enabled: true, - folderTemplate: "Hidden/{{date}}", - name: "Hidden Node Schedule", - nodeId: hiddenNode.id, - recordingProfileId: "voice-mp3-vbr", - room: "Hidden Room", - timezone: "UTC", - titleTemplate: "{{date}} Hidden Node Schedule", - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const updateHidden = await requestJson(app, `/api/v1/schedules/${visible.id}`, "PATCH", { - nodeId: hiddenNode.id, - }); - const runHidden = await app.request(`/api/v1/schedules/${hiddenNodeSchedule.id}/run-now`, { - method: "POST", - }); - const failedEvents = await auditStore.list({ - outcome: "failed", - permission: "schedule:manage", - }); - - assert.equal(createHidden.status, 409); - assert.equal(updateHidden.status, 409); - assert.equal(runHidden.status, 409); - assert.equal((await store.find(visible.id))?.nodeId, visibleNode.id); - assert.equal( - (await store.list()).some((candidate) => candidate.name === "Hidden Node Schedule"), - false, - ); - assert.equal((await recordings.list()).length, 0); - assert.deepEqual(failedEvents.map((event) => `${event.action}:${event.reason}`).sort(), [ - "schedules.create.failed:schedule_node_not_found", - "schedules.run_now.failed:schedule_node_not_found", - "schedules.update.failed:schedule_node_not_found", - ]); - assert.equal( - failedEvents.find((event) => event.action === "schedules.run_now.failed")?.target.id, - hiddenNodeSchedule.id, - ); -}); - -test("schedule routes create update run-now and skip-next with audit events", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["recording:read", "schedule:read", "schedule:manage"]); - const routeInterfaceId = "iface_route_jack"; - const routeNode = node({ - id: `node_schedule_ops_${randomUUID()}`, - interfaces: [ - { - alias: "JACK Route", - backend: "jack", - channelCount: 2, - channels: [ - { alias: "Left", index: 1 }, - { alias: "Right", index: 2 }, - ], - id: routeInterfaceId, - sampleRates: [48_000], - systemName: "jack:route", - systemRef: "jack:route", - }, - ], - }); - const schedules: ScheduleSummary[] = []; - const store = scheduleStore(schedules); - const recordings = recordingStore(); - const scheduleId = `sched_route_ops_${randomUUID()}`; - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([routeNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [routeNode], - scopedSchedules: () => store.list(), - settingsStore: createSettingsStore(), - }); - - const invalidInterface = await requestJson(app, "/api/v1/schedules", "POST", { - enabled: true, - captureInterfaceId: "missing_interface", - folderTemplate: "Meetings/{{date}}/{{schedule.name}}", - name: "Invalid Interface", - nodeId: routeNode.id, - recordingProfileId: "voice-mp3-vbr", - room: "Council Room", - timezone: "UTC", - titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const created = await requestJson(app, "/api/v1/schedules", "POST", { - captureBackend: "jack", - captureInterfaceId: routeInterfaceId, - enabled: true, - folderTemplate: "Meetings/{{date}}/{{schedule.name}}", - id: scheduleId, - name: "Council Route Test", - nodeId: routeNode.id, - recurrence: { - endTime: "10:00", - interval: 1, - mode: "daily", - startEarlySeconds: 300, - startTime: "09:00", - stopLateSeconds: 120, - }, - recordingProfileId: "voice-mp3-vbr", - retentionPolicyId: "retention-keep-controller-cache", - room: "Council Room", - tags: ["voice", "route", "voice"], - timezone: "UTC", - titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", - uploadPolicyIds: ["upload-policy-stub"], - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const createdBody = (await created.json()) as { data: ScheduleSummary }; - const updated = await requestJson(app, `/api/v1/schedules/${scheduleId}`, "PATCH", { - name: "Council Route Test Updated", - tags: ["updated", "voice", "updated"], - }); - const updatedBody = (await updated.json()) as { data: ScheduleSummary }; - const occurrences = await app.request(`/api/v1/schedules/${scheduleId}/occurrences?limit=2`); - const runNow = await app.request(`/api/v1/schedules/${scheduleId}/run-now`, { method: "POST" }); - const runNowBody = (await runNow.json()) as { - data: RecordingSummary; - job: { - command: { captureBackend?: string; captureDevice: string; captureInterfaceId?: string }; - recordingId: string; - }; - segments: Array<{ recordingId: string }>; - }; - const beforeSkip = await store.find(scheduleId); - const skipped = await app.request(`/api/v1/schedules/${scheduleId}/skip-next`, { - method: "POST", - }); - const skippedBody = (await skipped.json()) as { data: ScheduleSummary }; - const succeededAudits = await auditStore.list({ - outcome: "succeeded", - permission: "schedule:manage", - }); - const runNowAudit = succeededAudits.find( - (event) => event.action === "schedules.run_now.succeeded", - ); - const [occurrencesAudit] = await auditStore.list({ - action: "schedules.occurrences.read.succeeded", - }); - - assert.equal(invalidInterface.status, 409); - assert.equal(created.status, 201); - assert.equal(updated.status, 200); - assert.equal(occurrences.status, 200); - assert.equal(runNow.status, 202); - assert.equal(skipped.status, 200); - assert.equal(createdBody.data.id, scheduleId); - assert.equal(createdBody.data.captureBackend, "jack"); - assert.equal(createdBody.data.captureInterfaceId, routeInterfaceId); - assert.deepEqual(createdBody.data.tags, ["voice", "route"]); - assert.equal(updatedBody.data.name, "Council Route Test Updated"); - assert.deepEqual(updatedBody.data.tags, ["updated", "voice"]); - assert.equal(runNowBody.data.scheduleId, scheduleId); - assert.equal(runNowBody.data.retentionPolicyId, "retention-keep-controller-cache"); - assert.equal(runNowBody.job.command.captureBackend, "jack"); - assert.equal(runNowBody.job.command.captureDevice, "jack:route"); - assert.equal(runNowBody.job.command.captureInterfaceId, routeInterfaceId); - assert.equal(runNowBody.job.recordingId, runNowBody.data.id); - assert.deepEqual( - runNowBody.segments.map((segment) => segment.recordingId), - [runNowBody.data.id], - ); - assert.equal((await recordings.list())[0]?.source, "schedule"); - assert.notEqual(skippedBody.data.nextRunAt, beforeSkip?.nextRunAt); - assert.ok( - skippedBody.data.recurrence.exceptions?.some((exception) => exception.action === "skip"), - ); - assert.deepEqual(succeededAudits.map((event) => event.action).sort(), [ - "schedules.create.succeeded", - "schedules.run_now.succeeded", - "schedules.skip_next.succeeded", - "schedules.update.succeeded", - ]); - assert.equal(runNowAudit?.correlationIds?.scheduleId, scheduleId); - assert.equal(runNowAudit?.details.captureBackend, "jack"); - assert.equal(runNowAudit?.details.captureInterfaceId, routeInterfaceId); - assert.equal(runNowAudit?.after?.recordingId, runNowBody.data.id); - assert.equal(occurrencesAudit?.permission, "schedule:read"); - assert.equal(occurrencesAudit?.target.id, scheduleId); - assert.equal(occurrencesAudit?.details.requestedLimit, 2); - assert.equal(occurrencesAudit?.details.occurrenceCount, 2); -}); - -function requestJson( - app: Hono, - path: string, - method: "PATCH" | "POST", - body: Record, -) { - return app.request(path, { - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - method, - }); -} - -async function scheduleList(app: Hono, query = "") { - const response = await app.request(`/api/v1/schedules${query}`); - const body = (await response.json()) as { data: ScheduleSummary[] }; - - assert.equal(response.status, 200); - - return body.data; -} - -interface ScheduleActionsResponse { - data: { - actions: Record; - links: Record; - node?: RecorderNode; - schedule: ScheduleSummary; - }; -} - -function allowPermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function denyMissingPermission( - auditStore: ReturnType, - currentUser: CurrentUser, -): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function scheduleStore(schedules: ScheduleSummary[]): ScheduleStore { - return { - async create(schedule) { - schedules.unshift(schedule); - - return schedule; - }, - async delete(scheduleId) { - const index = schedules.findIndex((candidate) => candidate.id === scheduleId); - const [deleted] = index >= 0 ? schedules.splice(index, 1) : []; - - return deleted; - }, - async find(scheduleId) { - return schedules.find((candidate) => candidate.id === scheduleId); - }, - async list() { - return schedules; - }, - async update(scheduleId, update) { - const index = schedules.findIndex((candidate) => candidate.id === scheduleId); - - if (index < 0) { - return undefined; - } - - schedules[index] = { ...schedules[index], ...update }; - - return schedules[index]; - }, - }; -} - -function recordingStore() { - const recordings: RecordingSummary[] = []; - - return { - async create(recording: RecordingSummary) { - recordings.unshift(recording); - }, - async delete(recordingId: string) { - const index = recordings.findIndex((candidate) => candidate.id === recordingId); - const [deleted] = index >= 0 ? recordings.splice(index, 1) : []; - - return deleted; - }, - async find(recordingId: string) { - return recordings.find((candidate) => candidate.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording: RecordingSummary) { - recordings.unshift(recording); - }, - }; -} - -function user(permissions: Permission[] = ["schedule:read"]): CurrentUser { - return { - email: "schedule-viewer@example.com", - groups: [], - id: "user_schedule_viewer_test", - name: "Schedule Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function node(input: Partial = {}): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Schedule Node", - hostname: "schedule-node", - id: "node_schedule_test", - interfaces: [], - ipAddresses: ["10.0.0.60"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Council Room", - site: "Main Site", - }, - status: "online", - tags: ["voice"], - ...input, - }; -} - -function schedule(input: Partial = {}): ScheduleSummary { - return { - assignedGroupIds: [], - assignedUserIds: [], - enabled: true, - folderTemplate: "meetings/{{date}}", - id: "sched_route_test", - name: "Council Meeting", - nextRunAt: "2026-06-18T09:00:00.000Z", - nodeId: node().id, - recurrence: { mode: "manual" }, - recordingProfileId: "voice-mp3-vbr", - retentionPolicyId: "retention-keep-controller-cache", - room: "Council Room", - tags: ["council"], - timezone: "UTC", - titleTemplate: "{{date}} Council Meeting", - uploadPolicyIds: ["upload-policy-stub"], - watchdogPolicyId: "scheduled-voice-watchdog", - ...input, - }; -} diff --git a/apps/api/test/settings-routes-harness.ts b/apps/api/test/settings-routes-harness.ts new file mode 100644 index 00000000..25d5563d --- /dev/null +++ b/apps/api/test/settings-routes-harness.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { Hono } from "hono"; +import type { AuditEvent, CurrentUser } from "@rakkr/shared"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; + +// Shared harness for the settings route tests. Extracted from +// settings-routes.test.ts to keep each test file under the 1000-LOC guard. +// +// The store modules read their RAKKR_*_STORE_PATH env vars at import time, so +// the env setup below MUST run before the dynamic imports. + +const settingsRoot = await mkdtemp(path.join(tmpdir(), "rakkr-settings-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_STORE_PATH = path.join( + settingsRoot, + "channel-map-assignments.json", +); +process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_PLAN_STORE_PATH = path.join( + settingsRoot, + "channel-map-assignment-plans.json", +); +process.env.RAKKR_CHANNEL_MAP_TEMPLATE_STORE_PATH = path.join( + settingsRoot, + "channel-map-templates.json", +); +process.env.RAKKR_RECORDING_PROFILE_STORE_PATH = path.join(settingsRoot, "profiles.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(settingsRoot, "upload-policies.json"); +process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(settingsRoot, "upload-providers.json"); +process.env.RAKKR_WATCHDOG_POLICY_STORE_PATH = path.join(settingsRoot, "watchdog-policies.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { createChannelMapAssignmentPlanStore } = + await import("../src/channel-map-assignment-plans.js"); +const { registerSettingsRoutes } = await import("../src/settings-routes.js"); +const { createSettingsStore } = await import("../src/settings-store.js"); +const { createUploadPolicy } = await import("../src/upload-policies.js"); +const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); + +export { + createAuditStore, + createChannelMapAssignmentPlanStore, + createSettingsStore, + createUploadDestinationStore, + createUploadPolicy, + registerSettingsRoutes, +}; + +test.after(async () => { + await rm(settingsRoot, { force: true, recursive: true }); +}); + +export function requestJson( + app: Hono, + path: string, + method: "PATCH" | "POST" | "PUT", + body: Record, +) { + return app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, + }); +} + +export async function jsonData(app: Hono, path: string) { + const response = await app.request(path); + const body = (await response.json()) as { data: Record }; + + assert.equal(response.status, 200); + + return body.data; +} + +export function allowPermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function denyMissingPermission( + auditStore: ReturnType, + currentUser: CurrentUser, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function denyResourceScope( + auditStore: ReturnType, + currentUser: CurrentUser, + isVisibleTarget: (target: AuditTarget) => boolean, +): RequirePermission { + return (permission, action, target) => async (c, next) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + const hasPermission = currentUser.permissions.includes(permission); + const allowed = hasPermission && isVisibleTarget(auditTarget); + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: allowed ? "allowed" : "denied", + permission, + reason: hasPermission ? "access_policy_denied" : "missing_permission", + target: auditTarget, + }); + + if (!allowed) { + return c.json({ error: "Forbidden", permission }, 403); + } + + await next(); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function viewer(permissions = ["settings:read"]): CurrentUser { + return { + email: "settings-viewer@example.com", + groups: [], + id: "user_settings_viewer_test", + name: "Settings Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function channelMapInput(id: string, name: string) { + return { + channelMode: "mono_to_stereo_mix", + entries: [ + { + included: true, + label: "Podium Mic", + outputChannelIndex: 1, + sourceChannelIndex: 1, + }, + ], + id, + name, + tags: ["voice"], + }; +} diff --git a/apps/api/test/settings-routes-mutations.test.ts b/apps/api/test/settings-routes-mutations.test.ts new file mode 100644 index 00000000..b2ef6058 --- /dev/null +++ b/apps/api/test/settings-routes-mutations.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + channelMapInput, + createAuditStore, + createChannelMapAssignmentPlanStore, + createSettingsStore, + createUploadDestinationStore, + createUploadPolicy, + jsonData, + recordAuditEvent, + registerSettingsRoutes, + requestJson, + viewer, +} from "./settings-routes-harness.js"; + +test("settings detail routes return individual settings resources", async () => { + const app = new Hono(); + const currentUser = viewer(["settings:read"]); + const settingsStore = createSettingsStore(); + const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); + const uploadDestinationStore = createUploadDestinationStore(); + const template = await settingsStore.createChannelMapTemplate({ + channelMode: "mono_to_stereo_mix", + entries: [ + { + included: true, + label: "Detail Channel", + outputChannelIndex: 1, + sourceChannelIndex: 1, + }, + ], + id: `channel_map_detail_${randomUUID()}`, + name: "Detail Channel Map", + tags: ["detail"], + }); + const plan = await channelMapAssignmentPlanStore.create({ + targets: [{ targetId: "node_detail", targetType: "node" }], + templateId: template.id, + }); + const uploadPolicy = await createUploadPolicy({ + enabled: true, + id: `upload-policy-detail-${randomUUID()}`, + maxAttempts: 3, + name: "Detail Upload Policy", + trigger: "manual", + }); + const uploadDestination = await uploadDestinationStore.create({ + displayName: "Detail Destination", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + + registerSettingsRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + channelMapAssignmentPlanStore, + recordAuditEvent: recordAuditEvent(createAuditStore("")), + requirePermission: allowPermission(), + settingsStore, + uploadDestinationStore, + }); + + const profile = await jsonData(app, "/api/v1/settings/recording-profiles/voice-mp3-vbr"); + const watchdog = await jsonData( + app, + "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", + ); + const templateDetail = await jsonData( + app, + `/api/v1/settings/channel-map-templates/${template.id}`, + ); + const planDetail = await jsonData( + app, + `/api/v1/settings/channel-map-assignment-plans/${plan.id}`, + ); + const destination = await jsonData( + app, + `/api/v1/settings/upload-destinations/${uploadDestination.id}`, + ); + const policy = await jsonData(app, `/api/v1/settings/upload-policies/${uploadPolicy.id}`); + const missingProfile = await app.request("/api/v1/settings/recording-profiles/profile_missing"); + const missingTemplate = await app.request( + "/api/v1/settings/channel-map-templates/template_missing", + ); + const missingDestination = await app.request( + "/api/v1/settings/upload-destinations/destination_missing", + ); + + assert.equal(profile.id, "voice-mp3-vbr"); + assert.equal(watchdog.id, "scheduled-voice-watchdog"); + assert.equal(templateDetail.id, template.id); + assert.equal(planDetail.id, plan.id); + assert.equal(destination.id, uploadDestination.id); + assert.equal(policy.id, uploadPolicy.id); + assert.equal(missingProfile.status, 404); + assert.equal(missingTemplate.status, 404); + assert.equal(missingDestination.status, 404); +}); + +test("settings manage routes update operational templates and audit snapshots", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = viewer(["settings:read", "settings:manage"]); + const settingsStore = createSettingsStore(); + const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); + const uploadDestinationStore = createUploadDestinationStore(); + const primaryTemplateId = `channel_map_ops_${randomUUID()}`; + const rollbackTemplateId = `channel_map_rollback_${randomUUID()}`; + const uploadPolicyId = `upload-policy-ops-${randomUUID()}`; + const uploadDestination = await uploadDestinationStore.create({ + displayName: "Operations Destination", + enabled: true, + kind: "smb", + smb: { server: "ops.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + + registerSettingsRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + channelMapAssignmentPlanStore, + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allowPermission(), + settingsStore, + uploadDestinationStore, + }); + + const profileResponse = await requestJson( + app, + "/api/v1/settings/recording-profiles/voice-mp3-vbr", + "PATCH", + { + maxTrackSeconds: 900, + name: "Operations Voice MP3", + }, + ); + const watchdogResponse = await requestJson( + app, + "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", + "PATCH", + { + broadbandNoiseScoreThreshold: 0.84, + channelCorrelationMode: "alert_on_high", + channelCorrelationThreshold: 0.97, + clippingMode: "alert_on_clipping", + flatlineMode: "alert_on_flatline", + flatlineThresholdDbfs: -105, + humScoreThreshold: 0.76, + minCumulativeChannelCorrelationSeconds: 15, + minCumulativeClippingSeconds: 2, + minCumulativeFlatlineSeconds: 12, + minCumulativeQualitySeconds: 18, + minSpeechScore: 0.65, + name: "Operations Voice Watchdog", + noiseScoreThreshold: 0.88, + qualityAlertMode: "alert_on_noise_hum_static", + qualityMode: "speech_required", + staticScoreThreshold: 0.79, + }, + ); + const uploadCreateResponse = await requestJson(app, "/api/v1/settings/upload-policies", "POST", { + destinationId: uploadDestination.id, + enabled: true, + id: uploadPolicyId, + maxAttempts: 4, + name: "Operations Upload", + trigger: "manual", + }); + const uploadUpdateResponse = await requestJson( + app, + `/api/v1/settings/upload-policies/${uploadPolicyId}`, + "PATCH", + { + deleteCacheAfterUpload: true, + maxAttempts: 6, + }, + ); + const templateCreateResponse = await requestJson( + app, + "/api/v1/settings/channel-map-templates", + "POST", + channelMapInput(primaryTemplateId, "Operations Primary Map"), + ); + const templateUpdateResponse = await requestJson( + app, + `/api/v1/settings/channel-map-templates/${primaryTemplateId}`, + "PATCH", + { + name: "Operations Primary Map Rev 2", + tags: ["voice", "ops"], + }, + ); + await requestJson( + app, + "/api/v1/settings/channel-map-templates", + "POST", + channelMapInput(rollbackTemplateId, "Operations Rollback Map"), + ); + const assignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments", + "PUT", + { + targetId: "node_ops_room", + targetType: "node", + templateId: primaryTemplateId, + }, + ); + const reassignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments", + "PUT", + { + targetId: "node_ops_room", + targetType: "node", + templateId: rollbackTemplateId, + }, + ); + const bulkAssignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments/bulk", + "PUT", + { + targets: [ + { targetId: "node_ops_bulk", targetType: "node" }, + { targetId: "interface_ops_1", targetType: "interface" }, + { targetId: "interface_ops_1", targetType: "interface" }, + ], + templateId: rollbackTemplateId, + }, + ); + const planCreateResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignment-plans", + "POST", + { + note: "Stage council room rollout", + targets: [ + { targetId: "node_plan_room", targetType: "node" }, + { targetId: "interface_plan_1", targetType: "interface" }, + { targetId: "interface_plan_1", targetType: "interface" }, + ], + templateId: primaryTemplateId, + }, + ); + const planCreateBody = (await planCreateResponse.json()) as { + data: { id: string; status: string; targets: Array<{ targetId: string }> }; + }; + const planApplyResponse = await app.request( + `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, + { + method: "POST", + }, + ); + const planApplyAgainResponse = await app.request( + `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, + { + method: "POST", + }, + ); + const rollbackResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments/rollback", + "POST", + { + targetId: "node_ops_room", + targetType: "node", + }, + ); + await requestJson(app, "/api/v1/settings/recording-profiles", "POST", { + name: "Created Recording Profile", + }); + await requestJson(app, "/api/v1/settings/watchdog-policies", "POST", { + name: "Created Watchdog Policy", + }); + const audits = await auditStore.list({ outcome: "succeeded", permission: "settings:manage" }); + + assert.equal(profileResponse.status, 200); + assert.equal(watchdogResponse.status, 200); + assert.equal(uploadCreateResponse.status, 201); + assert.equal(uploadUpdateResponse.status, 200); + assert.equal(templateCreateResponse.status, 201); + assert.equal(templateUpdateResponse.status, 200); + assert.equal(assignmentResponse.status, 200); + assert.equal(reassignmentResponse.status, 200); + assert.equal(bulkAssignmentResponse.status, 200); + assert.equal(planCreateResponse.status, 201); + assert.equal(planApplyResponse.status, 200); + assert.equal(planApplyAgainResponse.status, 409); + assert.equal(rollbackResponse.status, 200); + + const updatedTemplate = (await templateUpdateResponse.json()) as { data: { revision: number } }; + const reassignment = (await reassignmentResponse.json()) as { + data: { history: Array<{ previousTemplateId?: string }>; templateId: string }; + }; + const bulkAssignment = (await bulkAssignmentResponse.json()) as { + data: Array<{ targetId: string; targetType: string; templateId: string }>; + }; + const planApply = (await planApplyResponse.json()) as { + data: { + assignments: Array<{ targetId: string; targetType: string; templateId: string }>; + plan: { status: string }; + }; + }; + const rollback = (await rollbackResponse.json()) as { data: { templateId: string } }; + + assert.equal(updatedTemplate.data.revision, 2); + assert.equal(reassignment.data.templateId, rollbackTemplateId); + assert.equal(reassignment.data.history.at(-1)?.previousTemplateId, primaryTemplateId); + assert.deepEqual( + bulkAssignment.data.map((assignment) => `${assignment.targetType}:${assignment.targetId}`), + ["node:node_ops_bulk", "interface:interface_ops_1"], + ); + assert.ok( + bulkAssignment.data.every((assignment) => assignment.templateId === rollbackTemplateId), + ); + assert.equal(planCreateBody.data.status, "pending"); + assert.deepEqual( + planCreateBody.data.targets.map((target) => target.targetId), + ["node_plan_room", "interface_plan_1"], + ); + assert.equal(planApply.data.plan.status, "applied"); + assert.deepEqual( + planApply.data.assignments.map( + (assignment) => `${assignment.targetType}:${assignment.targetId}`, + ), + ["node:node_plan_room", "interface:interface_plan_1"], + ); + assert.ok( + planApply.data.assignments.every((assignment) => assignment.templateId === primaryTemplateId), + ); + assert.equal(rollback.data.templateId, primaryTemplateId); + assert.deepEqual(audits.map((event) => event.action).sort(), [ + "settings.channel_map_assignment_plans.apply.succeeded", + "settings.channel_map_assignment_plans.create.succeeded", + "settings.channel_map_assignments.bulk_update.succeeded", + "settings.channel_map_assignments.rollback.succeeded", + "settings.channel_map_assignments.update.succeeded", + "settings.channel_map_assignments.update.succeeded", + "settings.channel_map_templates.create.succeeded", + "settings.channel_map_templates.create.succeeded", + "settings.channel_map_templates.update.succeeded", + "settings.recording_profiles.create.succeeded", + "settings.recording_profiles.update.succeeded", + "settings.upload_policies.create.succeeded", + "settings.upload_policies.update.succeeded", + "settings.watchdog_policies.create.succeeded", + "settings.watchdog_policies.update.succeeded", + ]); + + const profileAudit = audits.find( + (event) => event.action === "settings.recording_profiles.update.succeeded", + ); + + assert.equal(profileAudit?.before?.name, "Voice MP3 VBR"); + assert.equal(profileAudit?.after?.name, "Operations Voice MP3"); + const watchdogAudit = audits.find( + (event) => + event.action === "settings.watchdog_policies.update.succeeded" && + event.after?.name === "Operations Voice Watchdog", + ); + + assert.equal(watchdogAudit?.after?.channelCorrelationMode, "alert_on_high"); + assert.equal(watchdogAudit?.after?.broadbandNoiseScoreThreshold, 0.84); + assert.equal(watchdogAudit?.after?.channelCorrelationThreshold, 0.97); + assert.equal(watchdogAudit?.after?.clippingMode, "alert_on_clipping"); + assert.equal(watchdogAudit?.after?.flatlineMode, "alert_on_flatline"); + assert.equal(watchdogAudit?.after?.flatlineThresholdDbfs, -105); + assert.equal(watchdogAudit?.after?.humScoreThreshold, 0.76); + assert.equal(watchdogAudit?.after?.minCumulativeChannelCorrelationSeconds, 15); + assert.equal(watchdogAudit?.after?.minCumulativeClippingSeconds, 2); + assert.equal(watchdogAudit?.after?.minCumulativeFlatlineSeconds, 12); + assert.equal(watchdogAudit?.after?.minCumulativeQualitySeconds, 18); + assert.equal(watchdogAudit?.after?.noiseScoreThreshold, 0.88); + assert.equal(watchdogAudit?.after?.qualityAlertMode, "alert_on_noise_hum_static"); + assert.equal(watchdogAudit?.after?.staticScoreThreshold, 0.79); + const bulkAudit = audits.find( + (event) => event.action === "settings.channel_map_assignments.bulk_update.succeeded", + ); + + assert.equal(bulkAudit?.after?.targetCount, 2); + assert.equal(bulkAudit?.target.type, "channel_map_assignment_collection"); + const planApplyAudit = audits.find( + (event) => event.action === "settings.channel_map_assignment_plans.apply.succeeded", + ); + + assert.equal(planApplyAudit?.before?.status, "pending"); + assert.equal(planApplyAudit?.after?.plan.status, "applied"); + assert.equal(planApplyAudit?.after?.targetCount, 2); +}); diff --git a/apps/api/test/settings-routes.test.ts b/apps/api/test/settings-routes.test.ts index 0589daad..ebfe6f45 100644 --- a/apps/api/test/settings-routes.test.ts +++ b/apps/api/test/settings-routes.test.ts @@ -1,48 +1,19 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import type { AuditEvent, CurrentUser } from "@rakkr/shared"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; - -const settingsRoot = await mkdtemp(path.join(tmpdir(), "rakkr-settings-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_STORE_PATH = path.join( - settingsRoot, - "channel-map-assignments.json", -); -process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_PLAN_STORE_PATH = path.join( - settingsRoot, - "channel-map-assignment-plans.json", -); -process.env.RAKKR_CHANNEL_MAP_TEMPLATE_STORE_PATH = path.join( - settingsRoot, - "channel-map-templates.json", -); -process.env.RAKKR_RECORDING_PROFILE_STORE_PATH = path.join(settingsRoot, "profiles.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(settingsRoot, "upload-policies.json"); -process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(settingsRoot, "upload-providers.json"); -process.env.RAKKR_WATCHDOG_POLICY_STORE_PATH = path.join(settingsRoot, "watchdog-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createChannelMapAssignmentPlanStore } = - await import("../src/channel-map-assignment-plans.js"); -const { registerSettingsRoutes } = await import("../src/settings-routes.js"); -const { createSettingsStore } = await import("../src/settings-store.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); - -test.after(async () => { - await rm(settingsRoot, { force: true, recursive: true }); -}); +import type { AppBindings, AuditTarget } from "../src/http-types.js"; +import { + createAuditStore, + createSettingsStore, + createUploadDestinationStore, + denyMissingPermission, + denyResourceScope, + recordAuditEvent, + registerSettingsRoutes, + requestJson, + viewer, +} from "./settings-routes-harness.js"; test("settings write routes deny users without settings manage", async () => { const app = new Hono(); @@ -474,526 +445,3 @@ test("channel map template routes honor resource-scope denies", async () => { ), ); }); - -test("settings detail routes return individual settings resources", async () => { - const app = new Hono(); - const currentUser = viewer(["settings:read"]); - const settingsStore = createSettingsStore(); - const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); - const uploadDestinationStore = createUploadDestinationStore(); - const template = await settingsStore.createChannelMapTemplate({ - channelMode: "mono_to_stereo_mix", - entries: [ - { - included: true, - label: "Detail Channel", - outputChannelIndex: 1, - sourceChannelIndex: 1, - }, - ], - id: `channel_map_detail_${randomUUID()}`, - name: "Detail Channel Map", - tags: ["detail"], - }); - const plan = await channelMapAssignmentPlanStore.create({ - targets: [{ targetId: "node_detail", targetType: "node" }], - templateId: template.id, - }); - const uploadPolicy = await createUploadPolicy({ - enabled: true, - id: `upload-policy-detail-${randomUUID()}`, - maxAttempts: 3, - name: "Detail Upload Policy", - trigger: "manual", - }); - const uploadDestination = await uploadDestinationStore.create({ - displayName: "Detail Destination", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - - registerSettingsRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - channelMapAssignmentPlanStore, - recordAuditEvent: recordAuditEvent(createAuditStore("")), - requirePermission: allowPermission(), - settingsStore, - uploadDestinationStore, - }); - - const profile = await jsonData(app, "/api/v1/settings/recording-profiles/voice-mp3-vbr"); - const watchdog = await jsonData( - app, - "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", - ); - const templateDetail = await jsonData( - app, - `/api/v1/settings/channel-map-templates/${template.id}`, - ); - const planDetail = await jsonData( - app, - `/api/v1/settings/channel-map-assignment-plans/${plan.id}`, - ); - const destination = await jsonData( - app, - `/api/v1/settings/upload-destinations/${uploadDestination.id}`, - ); - const policy = await jsonData(app, `/api/v1/settings/upload-policies/${uploadPolicy.id}`); - const missingProfile = await app.request("/api/v1/settings/recording-profiles/profile_missing"); - const missingTemplate = await app.request( - "/api/v1/settings/channel-map-templates/template_missing", - ); - const missingDestination = await app.request( - "/api/v1/settings/upload-destinations/destination_missing", - ); - - assert.equal(profile.id, "voice-mp3-vbr"); - assert.equal(watchdog.id, "scheduled-voice-watchdog"); - assert.equal(templateDetail.id, template.id); - assert.equal(planDetail.id, plan.id); - assert.equal(destination.id, uploadDestination.id); - assert.equal(policy.id, uploadPolicy.id); - assert.equal(missingProfile.status, 404); - assert.equal(missingTemplate.status, 404); - assert.equal(missingDestination.status, 404); -}); - -test("settings manage routes update operational templates and audit snapshots", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = viewer(["settings:read", "settings:manage"]); - const settingsStore = createSettingsStore(); - const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); - const uploadDestinationStore = createUploadDestinationStore(); - const primaryTemplateId = `channel_map_ops_${randomUUID()}`; - const rollbackTemplateId = `channel_map_rollback_${randomUUID()}`; - const uploadPolicyId = `upload-policy-ops-${randomUUID()}`; - const uploadDestination = await uploadDestinationStore.create({ - displayName: "Operations Destination", - enabled: true, - kind: "smb", - smb: { server: "ops.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - - registerSettingsRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - channelMapAssignmentPlanStore, - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allowPermission(), - settingsStore, - uploadDestinationStore, - }); - - const profileResponse = await requestJson( - app, - "/api/v1/settings/recording-profiles/voice-mp3-vbr", - "PATCH", - { - maxTrackSeconds: 900, - name: "Operations Voice MP3", - }, - ); - const watchdogResponse = await requestJson( - app, - "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", - "PATCH", - { - broadbandNoiseScoreThreshold: 0.84, - channelCorrelationMode: "alert_on_high", - channelCorrelationThreshold: 0.97, - clippingMode: "alert_on_clipping", - flatlineMode: "alert_on_flatline", - flatlineThresholdDbfs: -105, - humScoreThreshold: 0.76, - minCumulativeChannelCorrelationSeconds: 15, - minCumulativeClippingSeconds: 2, - minCumulativeFlatlineSeconds: 12, - minCumulativeQualitySeconds: 18, - minSpeechScore: 0.65, - name: "Operations Voice Watchdog", - noiseScoreThreshold: 0.88, - qualityAlertMode: "alert_on_noise_hum_static", - qualityMode: "speech_required", - staticScoreThreshold: 0.79, - }, - ); - const uploadCreateResponse = await requestJson(app, "/api/v1/settings/upload-policies", "POST", { - destinationId: uploadDestination.id, - enabled: true, - id: uploadPolicyId, - maxAttempts: 4, - name: "Operations Upload", - trigger: "manual", - }); - const uploadUpdateResponse = await requestJson( - app, - `/api/v1/settings/upload-policies/${uploadPolicyId}`, - "PATCH", - { - deleteCacheAfterUpload: true, - maxAttempts: 6, - }, - ); - const templateCreateResponse = await requestJson( - app, - "/api/v1/settings/channel-map-templates", - "POST", - channelMapInput(primaryTemplateId, "Operations Primary Map"), - ); - const templateUpdateResponse = await requestJson( - app, - `/api/v1/settings/channel-map-templates/${primaryTemplateId}`, - "PATCH", - { - name: "Operations Primary Map Rev 2", - tags: ["voice", "ops"], - }, - ); - await requestJson( - app, - "/api/v1/settings/channel-map-templates", - "POST", - channelMapInput(rollbackTemplateId, "Operations Rollback Map"), - ); - const assignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments", - "PUT", - { - targetId: "node_ops_room", - targetType: "node", - templateId: primaryTemplateId, - }, - ); - const reassignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments", - "PUT", - { - targetId: "node_ops_room", - targetType: "node", - templateId: rollbackTemplateId, - }, - ); - const bulkAssignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments/bulk", - "PUT", - { - targets: [ - { targetId: "node_ops_bulk", targetType: "node" }, - { targetId: "interface_ops_1", targetType: "interface" }, - { targetId: "interface_ops_1", targetType: "interface" }, - ], - templateId: rollbackTemplateId, - }, - ); - const planCreateResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignment-plans", - "POST", - { - note: "Stage council room rollout", - targets: [ - { targetId: "node_plan_room", targetType: "node" }, - { targetId: "interface_plan_1", targetType: "interface" }, - { targetId: "interface_plan_1", targetType: "interface" }, - ], - templateId: primaryTemplateId, - }, - ); - const planCreateBody = (await planCreateResponse.json()) as { - data: { id: string; status: string; targets: Array<{ targetId: string }> }; - }; - const planApplyResponse = await app.request( - `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, - { - method: "POST", - }, - ); - const planApplyAgainResponse = await app.request( - `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, - { - method: "POST", - }, - ); - const rollbackResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments/rollback", - "POST", - { - targetId: "node_ops_room", - targetType: "node", - }, - ); - await requestJson(app, "/api/v1/settings/recording-profiles", "POST", { - name: "Created Recording Profile", - }); - await requestJson(app, "/api/v1/settings/watchdog-policies", "POST", { - name: "Created Watchdog Policy", - }); - const audits = await auditStore.list({ outcome: "succeeded", permission: "settings:manage" }); - - assert.equal(profileResponse.status, 200); - assert.equal(watchdogResponse.status, 200); - assert.equal(uploadCreateResponse.status, 201); - assert.equal(uploadUpdateResponse.status, 200); - assert.equal(templateCreateResponse.status, 201); - assert.equal(templateUpdateResponse.status, 200); - assert.equal(assignmentResponse.status, 200); - assert.equal(reassignmentResponse.status, 200); - assert.equal(bulkAssignmentResponse.status, 200); - assert.equal(planCreateResponse.status, 201); - assert.equal(planApplyResponse.status, 200); - assert.equal(planApplyAgainResponse.status, 409); - assert.equal(rollbackResponse.status, 200); - - const updatedTemplate = (await templateUpdateResponse.json()) as { data: { revision: number } }; - const reassignment = (await reassignmentResponse.json()) as { - data: { history: Array<{ previousTemplateId?: string }>; templateId: string }; - }; - const bulkAssignment = (await bulkAssignmentResponse.json()) as { - data: Array<{ targetId: string; targetType: string; templateId: string }>; - }; - const planApply = (await planApplyResponse.json()) as { - data: { - assignments: Array<{ targetId: string; targetType: string; templateId: string }>; - plan: { status: string }; - }; - }; - const rollback = (await rollbackResponse.json()) as { data: { templateId: string } }; - - assert.equal(updatedTemplate.data.revision, 2); - assert.equal(reassignment.data.templateId, rollbackTemplateId); - assert.equal(reassignment.data.history.at(-1)?.previousTemplateId, primaryTemplateId); - assert.deepEqual( - bulkAssignment.data.map((assignment) => `${assignment.targetType}:${assignment.targetId}`), - ["node:node_ops_bulk", "interface:interface_ops_1"], - ); - assert.ok( - bulkAssignment.data.every((assignment) => assignment.templateId === rollbackTemplateId), - ); - assert.equal(planCreateBody.data.status, "pending"); - assert.deepEqual( - planCreateBody.data.targets.map((target) => target.targetId), - ["node_plan_room", "interface_plan_1"], - ); - assert.equal(planApply.data.plan.status, "applied"); - assert.deepEqual( - planApply.data.assignments.map( - (assignment) => `${assignment.targetType}:${assignment.targetId}`, - ), - ["node:node_plan_room", "interface:interface_plan_1"], - ); - assert.ok( - planApply.data.assignments.every((assignment) => assignment.templateId === primaryTemplateId), - ); - assert.equal(rollback.data.templateId, primaryTemplateId); - assert.deepEqual(audits.map((event) => event.action).sort(), [ - "settings.channel_map_assignment_plans.apply.succeeded", - "settings.channel_map_assignment_plans.create.succeeded", - "settings.channel_map_assignments.bulk_update.succeeded", - "settings.channel_map_assignments.rollback.succeeded", - "settings.channel_map_assignments.update.succeeded", - "settings.channel_map_assignments.update.succeeded", - "settings.channel_map_templates.create.succeeded", - "settings.channel_map_templates.create.succeeded", - "settings.channel_map_templates.update.succeeded", - "settings.recording_profiles.create.succeeded", - "settings.recording_profiles.update.succeeded", - "settings.upload_policies.create.succeeded", - "settings.upload_policies.update.succeeded", - "settings.watchdog_policies.create.succeeded", - "settings.watchdog_policies.update.succeeded", - ]); - - const profileAudit = audits.find( - (event) => event.action === "settings.recording_profiles.update.succeeded", - ); - - assert.equal(profileAudit?.before?.name, "Voice MP3 VBR"); - assert.equal(profileAudit?.after?.name, "Operations Voice MP3"); - const watchdogAudit = audits.find( - (event) => - event.action === "settings.watchdog_policies.update.succeeded" && - event.after?.name === "Operations Voice Watchdog", - ); - - assert.equal(watchdogAudit?.after?.channelCorrelationMode, "alert_on_high"); - assert.equal(watchdogAudit?.after?.broadbandNoiseScoreThreshold, 0.84); - assert.equal(watchdogAudit?.after?.channelCorrelationThreshold, 0.97); - assert.equal(watchdogAudit?.after?.clippingMode, "alert_on_clipping"); - assert.equal(watchdogAudit?.after?.flatlineMode, "alert_on_flatline"); - assert.equal(watchdogAudit?.after?.flatlineThresholdDbfs, -105); - assert.equal(watchdogAudit?.after?.humScoreThreshold, 0.76); - assert.equal(watchdogAudit?.after?.minCumulativeChannelCorrelationSeconds, 15); - assert.equal(watchdogAudit?.after?.minCumulativeClippingSeconds, 2); - assert.equal(watchdogAudit?.after?.minCumulativeFlatlineSeconds, 12); - assert.equal(watchdogAudit?.after?.minCumulativeQualitySeconds, 18); - assert.equal(watchdogAudit?.after?.noiseScoreThreshold, 0.88); - assert.equal(watchdogAudit?.after?.qualityAlertMode, "alert_on_noise_hum_static"); - assert.equal(watchdogAudit?.after?.staticScoreThreshold, 0.79); - const bulkAudit = audits.find( - (event) => event.action === "settings.channel_map_assignments.bulk_update.succeeded", - ); - - assert.equal(bulkAudit?.after?.targetCount, 2); - assert.equal(bulkAudit?.target.type, "channel_map_assignment_collection"); - const planApplyAudit = audits.find( - (event) => event.action === "settings.channel_map_assignment_plans.apply.succeeded", - ); - - assert.equal(planApplyAudit?.before?.status, "pending"); - assert.equal(planApplyAudit?.after?.plan.status, "applied"); - assert.equal(planApplyAudit?.after?.targetCount, 2); -}); - -function requestJson( - app: Hono, - path: string, - method: "PATCH" | "POST" | "PUT", - body: Record, -) { - return app.request(path, { - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - method, - }); -} - -async function jsonData(app: Hono, path: string) { - const response = await app.request(path); - const body = (await response.json()) as { data: Record }; - - assert.equal(response.status, 200); - - return body.data; -} - -function allowPermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function denyMissingPermission( - auditStore: ReturnType, - currentUser: CurrentUser, -): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function denyResourceScope( - auditStore: ReturnType, - currentUser: CurrentUser, - isVisibleTarget: (target: AuditTarget) => boolean, -): RequirePermission { - return (permission, action, target) => async (c, next) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - const hasPermission = currentUser.permissions.includes(permission); - const allowed = hasPermission && isVisibleTarget(auditTarget); - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: allowed ? "allowed" : "denied", - permission, - reason: hasPermission ? "access_policy_denied" : "missing_permission", - target: auditTarget, - }); - - if (!allowed) { - return c.json({ error: "Forbidden", permission }, 403); - } - - await next(); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function viewer(permissions = ["settings:read"]): CurrentUser { - return { - email: "settings-viewer@example.com", - groups: [], - id: "user_settings_viewer_test", - name: "Settings Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function channelMapInput(id: string, name: string) { - return { - channelMode: "mono_to_stereo_mix", - entries: [ - { - included: true, - label: "Podium Mic", - outputChannelIndex: 1, - sourceChannelIndex: 1, - }, - ], - id, - name, - tags: ["voice"], - }; -} diff --git a/apps/api/test/upload-runner-harness.ts b/apps/api/test/upload-runner-harness.ts new file mode 100644 index 00000000..b1b76001 --- /dev/null +++ b/apps/api/test/upload-runner-harness.ts @@ -0,0 +1,191 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { AuditEvent, CurrentUser, RecordingSummary } from "@rakkr/shared"; +import type { RecordAuditEvent, RequirePermission } from "../src/http-types.js"; + +export { memoryRecordingStore } from "./recording-store-mock.js"; + +export const runnerRoot = await mkdtemp(path.join(tmpdir(), "rakkr-upload-runner-")); +process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(runnerRoot, "destinations.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(runnerRoot, "policies.json"); +process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(runnerRoot, "queue.json"); +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(runnerRoot, "cache"); +process.env.RAKKR_RECORDING_CHUNK_STORE_PATH = path.join(runnerRoot, "chunks.json"); + +export const { createAuditStore } = await import("../src/audit-store.js"); +export const { createHealthEventStore } = await import("../src/health-store.js"); +export const { createUploadPolicy } = await import("../src/upload-policies.js"); +export const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); +export const { registerUploadRunnerRoutes } = await import("../src/upload-runner-routes.js"); +export const { createUploadRunner } = await import("../src/upload-runner.js"); +export const { enqueueRecordingUpload, listUploadQueueItems } = + await import("../src/upload-queue.js"); +export const { upsertRecordingChunk } = await import("../src/recording-chunks.js"); + +test.after(async () => { + await rm(runnerRoot, { force: true, recursive: true }); +}); + +export const allow: RequirePermission = () => async (_c, next) => { + await next(); +}; + +export function denyMissingPermission( + auditStore: ReturnType, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: viewer() }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: ["viewer"], + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function user(): CurrentUser { + return { + email: "upload-runner@example.com", + groups: [], + id: "user_upload_runner_test", + name: "Upload Runner Test", + permissions: ["recording:control", "recording:read"], + provider: "local", + resourceGrants: [], + roles: ["admin"], + }; +} + +export function viewer(permissions: CurrentUser["permissions"] = []): CurrentUser { + return { + email: "upload-runner-viewer@example.com", + groups: [], + id: "user_upload_runner_viewer_test", + name: "Upload Runner Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function recording(id = "rec_upload_runner_test", contents?: string): RecordingSummary { + return { + cachePath: `scheduled/${id}.mp3`, + cached: true, + checksum: contents ? sha256Prefixed(contents) : `sha256:${id}`, + durationSeconds: 900, + folder: "Meetings/2026", + healthStatus: "healthy", + id, + name: "Council Meeting", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "schedule", + status: "cached", + tags: ["council"], + }; +} + +export async function cacheRecording(id: string, contents: string) { + const cachePath = path.join(runnerRoot, "cache", "scheduled", `${id}.mp3`); + + await mkdir(path.dirname(cachePath), { recursive: true }); + await writeFile(cachePath, contents); + + return cachePath; +} + +export function sha256Prefixed(contents: string) { + return `sha256:${createHash("sha256").update(contents).digest("hex")}`; +} + +export function throwingSmbClient() { + return { + async close() {}, + async connect() { + throw new Error("smb_connect_failed"); + }, + async mkdir() {}, + async readFile() { + return Buffer.alloc(0); + }, + async writeFile() {}, + }; +} + +export function fakeSmbClient() { + const files = new Map(); + const dirs: string[] = []; + + return { + client: { + async close() {}, + async connect() {}, + async mkdir(targetPath: string) { + dirs.push(targetPath); + }, + async readFile(targetPath: string) { + const data = files.get(targetPath); + + if (!data) { + const error = new Error("ENOENT") as Error & { code: string }; + error.code = "ENOENT"; + throw error; + } + + return data; + }, + async writeFile(targetPath: string, data: Buffer | string) { + files.set(targetPath, Buffer.from(data)); + }, + }, + dirs, + files, + }; +} diff --git a/apps/api/test/upload-runner-reconcile.test.ts b/apps/api/test/upload-runner-reconcile.test.ts new file mode 100644 index 00000000..d45a7fd6 --- /dev/null +++ b/apps/api/test/upload-runner-reconcile.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + cacheRecording, + createAuditStore, + createUploadDestinationStore, + createUploadPolicy, + createUploadRunner, + enqueueRecordingUpload, + fakeSmbClient, + memoryRecordingStore, + recording, + runnerRoot, + upsertRecordingChunk, +} from "./upload-runner-harness.js"; + +test("G54: reconcile does not overwrite a recording a retry reset to `recording`", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "retry-race-bytes"; + // Mid-retry: the recording was reset to `recording`, but a prior attempt left + // a settled upload-queue item the reconcile would otherwise promote. + const retried = { + ...recording("rec_upload_retry_race", contents), + status: "recording" as const, + }; + const cachePath = await cacheRecording(retried.id, contents); + const recordingStore = memoryRecordingStore([retried]); + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + + const destination = await destinationStore.create({ + displayName: "Race Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: true, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Race then delete cache", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + destinationId: destination.id, + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + const updated = await recordingStore.find(retried.id); + + // Pre-fix the reconcile blind-saved "uploaded" over the retry's fresh state + // and deleted its cache; now it re-reads and skips. + assert.equal(updated?.status, "recording"); + await assert.doesNotReject(readFile(cachePath)); +}); + +test("G55: chunked reconcile does not promote a recording reset to `recording`", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "chunk-race-bytes"; + const retried = { + ...recording("rec_upload_chunk_race", contents), + status: "recording" as const, + }; + const recordingStore = memoryRecordingStore([retried]); + const chunkId = "rec_upload_chunk_race:1"; + const chunkCacheRel = `chunks/${chunkId}.mp3`; + const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); + + await mkdir(path.dirname(chunkCachePath), { recursive: true }); + await writeFile(chunkCachePath, contents); + await upsertRecordingChunk({ + cachePath: chunkCacheRel, + createdAt: "2026-06-18T12:00:00.000Z", + durationSeconds: 60, + id: chunkId, + index: 1, + jobId: "job_chunk_race", + offsetSeconds: 0, + recordingId: retried.id, + status: "cached", + total: 1, + }); + + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + + const destination = await destinationStore.create({ + displayName: "Chunk Race Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: false, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Chunk race policy", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + cachePath: chunkCacheRel, + chunkId, + chunkIndex: 1, + destinationId: destination.id, + fileName: "chunk-1.mp3", + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + const updated = await recordingStore.find(retried.id); + + // Pre-fix the chunked reconcile blind-saved "uploaded" over the retry state. + assert.equal(updated?.status, "recording"); +}); + +test("G55-d: a stale chunked reconcile does not delete a re-captured chunk's cache", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "chunk-recapture-bytes"; + const retried = { + ...recording("rec_upload_chunk_recapture", contents), + status: "recording" as const, + }; + const recordingStore = memoryRecordingStore([retried]); + const chunkId = "rec_upload_chunk_recapture:1"; + const chunkCacheRel = `chunks/${chunkId}.mp3`; + const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); + + await mkdir(path.dirname(chunkCachePath), { recursive: true }); + await writeFile(chunkCachePath, contents); + await upsertRecordingChunk({ + cachePath: chunkCacheRel, + createdAt: "2026-06-18T12:00:00.000Z", + durationSeconds: 60, + id: chunkId, + index: 1, + jobId: "job_chunk_recapture", + offsetSeconds: 0, + recordingId: retried.id, + status: "cached", + total: 1, + }); + + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + const destination = await destinationStore.create({ + displayName: "Recapture Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: true, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Recapture delete policy", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + cachePath: chunkCacheRel, + chunkId, + chunkIndex: 1, + destinationId: destination.id, + fileName: "chunk-1.mp3", + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + + // Pre-fix the per-chunk deletion ran before the recording-status guard, so a + // stale pass (recording reset to `recording` by a retry) deleted the + // re-captured chunk's cache. The hoisted guard now skips the whole pass. + await assert.doesNotReject(readFile(chunkCachePath)); +}); diff --git a/apps/api/test/upload-runner-routes.test.ts b/apps/api/test/upload-runner-routes.test.ts new file mode 100644 index 00000000..b2dba414 --- /dev/null +++ b/apps/api/test/upload-runner-routes.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + allow, + createAuditStore, + createUploadDestinationStore, + createUploadRunner, + denyMissingPermission, + enqueueRecordingUpload, + listUploadQueueItems, + recordAuditEvent, + recording, + registerUploadRunnerRoutes, + user, + viewer, +} from "./upload-runner-harness.js"; + +test("upload runner routes expose status and run-now control", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + uploadRunner: runner, + }); + await enqueueRecordingUpload(recording("rec_upload_runner_route_test"), { + provider: "stub", + target: "stub://queue-only", + }); + + const before = await app.request("/api/v1/upload-runner"); + const actions = await app.request("/api/v1/upload-runner/actions"); + const run = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const actionPayload = (await actions.json()) as { + data: { actions: { run: { enabled: boolean; href?: string } } }; + }; + const payload = await run.json(); + const readEvents = await auditStore.list({ + action: "recordings.upload_runner.read.succeeded", + }); + const actionEvents = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + const events = await auditStore.list({ action: "recordings.upload_runner.run.succeeded" }); + + assert.equal(before.status, 200); + assert.equal(actions.status, 200); + assert.equal(actionPayload.data.actions.run.enabled, true); + assert.equal(actionPayload.data.actions.run.href, "/api/v1/upload-runner/run"); + assert.equal(run.status, 200); + assert.equal(payload.summary.succeeded, 1); + assert.equal(payload.data.lastSummary.succeeded, 1); + assert.equal(readEvents.length, 1); + assert.equal(readEvents[0]?.permission, "recording:read"); + assert.equal(readEvents[0]?.target.type, "upload_runner"); + assert.equal(readEvents[0]?.details.started, false); + assert.equal(readEvents[0]?.details.lastSummaryAttempted, 0); + assert.equal(actionEvents.length, 1); + assert.equal(actionEvents[0]?.permission, "recording:read"); + assert.equal(actionEvents[0]?.target.type, "upload_runner"); + assert.equal(actionEvents[0]?.details.started, false); + assert.equal(actionEvents[0]?.details.visibleActionCount, 2); + assert.equal(events.length, 1); + assert.equal(events[0]?.actor.id, "user_upload_runner_test"); +}); + +test("upload runner run route only processes queue items for scoped recordings", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + const visible = recording(`rec_upload_visible_${randomUUID()}`); + const hidden = recording(`rec_upload_hidden_${randomUUID()}`); + const visibleItem = await enqueueRecordingUpload(visible, { + provider: "stub", + target: "stub://visible", + }); + const hiddenItem = await enqueueRecordingUpload(hidden, { + provider: "stub", + target: "stub://hidden", + }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + scopedRecordings: async () => [visible], + uploadRunner: runner, + }); + + const response = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const body = (await response.json()) as { + summary: { attempted: number; items: Array<{ recordingId: string }>; succeeded: number }; + }; + const items = await listUploadQueueItems(); + const storedVisible = items.find((item) => item.id === visibleItem.id); + const storedHidden = items.find((item) => item.id === hiddenItem.id); + const itemEvents = await auditStore.list({ + action: "recordings.upload_queue.runner_item.succeeded", + }); + + assert.equal(response.status, 200); + assert.equal(body.summary.attempted, 1); + assert.equal(body.summary.succeeded, 1); + assert.deepEqual( + body.summary.items.map((item) => item.recordingId), + [visible.id], + ); + assert.equal(storedVisible?.status, "succeeded"); + assert.equal(storedHidden?.status, "queued"); + assert.equal(storedHidden?.attemptCount, 0); + assert.deepEqual( + itemEvents.map((event) => event.target.id), + [visible.id], + ); +}); + +test("upload runner status routes hide last-summary items outside scoped recordings", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + const visible = recording(`rec_upload_status_visible_${randomUUID()}`); + const hidden = recording(`rec_upload_status_hidden_${randomUUID()}`); + + await enqueueRecordingUpload(visible, { + provider: "stub", + target: "stub://visible", + }); + await enqueueRecordingUpload(hidden, { + provider: "stub", + target: "stub://hidden", + }); + await runner.runOnce(); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + scopedRecordings: async () => [visible], + uploadRunner: runner, + }); + + const statusResponse = await app.request("/api/v1/upload-runner"); + const actionsResponse = await app.request("/api/v1/upload-runner/actions"); + const statusBody = (await statusResponse.json()) as { + data: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; + }; + const actionsBody = (await actionsResponse.json()) as { + data: { + status: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; + }; + }; + const [readEvent] = await auditStore.list({ + action: "recordings.upload_runner.read.succeeded", + }); + const [actionEvent] = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + + assert.equal(statusResponse.status, 200); + assert.equal(actionsResponse.status, 200); + assert.ok((runner.status().lastSummary?.attempted ?? 0) > 1); + assert.equal(statusBody.data.lastSummary?.attempted, 1); + assert.deepEqual( + statusBody.data.lastSummary?.items.map((item) => item.recordingId), + [visible.id], + ); + assert.deepEqual(actionsBody.data.status.lastSummary, statusBody.data.lastSummary); + assert.equal(readEvent?.details.lastSummaryAttempted, 1); + assert.equal(readEvent?.details.lastSummaryItemCount, 1); + assert.equal(actionEvent?.details.lastSummaryAttempted, 1); + assert.equal(actionEvent?.details.lastSummaryItemCount, 1); +}); + +test("upload runner routes deny users without required permissions", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: viewer() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: denyMissingPermission(auditStore), + uploadRunner: runner, + }); + await enqueueRecordingUpload(recording("rec_upload_runner_denied_route_test"), { + provider: "stub", + target: "stub://queue-only", + }); + + const readResponse = await app.request("/api/v1/upload-runner"); + const actionsResponse = await app.request("/api/v1/upload-runner/actions"); + const runResponse = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const status = runner.status(); + const events = await auditStore.list({ outcome: "denied" }); + + assert.deepEqual( + [readResponse.status, actionsResponse.status, runResponse.status], + [403, 403, 403], + ); + assert.equal(status.lastSummary, undefined); + assert.deepEqual( + Object.fromEntries(events.map((event) => [event.action, event.permission]).sort()), + { + "recordings.upload_runner.actions.read": "recording:read", + "recordings.upload_runner.read": "recording:read", + "recordings.upload_runner.run": "recording:control", + }, + ); + assert.ok(events.every((event) => event.reason === "missing_permission")); + assert.ok(events.every((event) => event.target.type === "upload_runner")); +}); + +test("upload runner action summary reports missing control permission", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: viewer(["recording:read"]) }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + uploadRunner: runner, + }); + + const response = await app.request("/api/v1/upload-runner/actions"); + const body = (await response.json()) as { + data: { actions: { run: { enabled: boolean; reason?: string } } }; + }; + const [event] = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + + assert.equal(response.status, 200); + assert.equal(body.data.actions.run.enabled, false); + assert.equal(body.data.actions.run.reason, "missing_permission"); + assert.equal(event?.outcome, "succeeded"); + assert.equal(event?.details.visibleActionCount, 2); +}); diff --git a/apps/api/test/upload-runner.test.ts b/apps/api/test/upload-runner.test.ts index d08be542..7718faf2 100644 --- a/apps/api/test/upload-runner.test.ts +++ b/apps/api/test/upload-runner.test.ts @@ -1,33 +1,23 @@ import assert from "node:assert/strict"; -import { createHash, randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; -import type { AuditEvent, CurrentUser, RecordingSummary } from "@rakkr/shared"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import { memoryRecordingStore } from "./recording-store-mock.js"; - -const runnerRoot = await mkdtemp(path.join(tmpdir(), "rakkr-upload-runner-")); -process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(runnerRoot, "destinations.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(runnerRoot, "policies.json"); -process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(runnerRoot, "queue.json"); -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(runnerRoot, "cache"); -process.env.RAKKR_RECORDING_CHUNK_STORE_PATH = path.join(runnerRoot, "chunks.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); -const { registerUploadRunnerRoutes } = await import("../src/upload-runner-routes.js"); -const { createUploadRunner } = await import("../src/upload-runner.js"); -const { enqueueRecordingUpload, listUploadQueueItems } = await import("../src/upload-queue.js"); -const { upsertRecordingChunk } = await import("../src/recording-chunks.js"); - -test.after(async () => { - await rm(runnerRoot, { force: true, recursive: true }); -}); +import { + cacheRecording, + createAuditStore, + createHealthEventStore, + createUploadDestinationStore, + createUploadPolicy, + createUploadRunner, + enqueueRecordingUpload, + fakeSmbClient, + memoryRecordingStore, + recording, + runnerRoot, + sha256Prefixed, + throwingSmbClient, + upsertRecordingChunk, +} from "./upload-runner-harness.js"; test("upload runner processes queue items and records service audit events", async () => { const auditStore = createAuditStore(""); @@ -382,587 +372,3 @@ test("upload runner keeps a chunk's cache when one destination fails (chunked re // deletes cache — the chunk analogue of the whole-recording partial gate. await assert.doesNotReject(readFile(chunkCachePath)); }); - -test("G54: reconcile does not overwrite a recording a retry reset to `recording`", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "retry-race-bytes"; - // Mid-retry: the recording was reset to `recording`, but a prior attempt left - // a settled upload-queue item the reconcile would otherwise promote. - const retried = { - ...recording("rec_upload_retry_race", contents), - status: "recording" as const, - }; - const cachePath = await cacheRecording(retried.id, contents); - const recordingStore = memoryRecordingStore([retried]); - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - - const destination = await destinationStore.create({ - displayName: "Race Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: true, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Race then delete cache", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - destinationId: destination.id, - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - const updated = await recordingStore.find(retried.id); - - // Pre-fix the reconcile blind-saved "uploaded" over the retry's fresh state - // and deleted its cache; now it re-reads and skips. - assert.equal(updated?.status, "recording"); - await assert.doesNotReject(readFile(cachePath)); -}); - -test("G55: chunked reconcile does not promote a recording reset to `recording`", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "chunk-race-bytes"; - const retried = { - ...recording("rec_upload_chunk_race", contents), - status: "recording" as const, - }; - const recordingStore = memoryRecordingStore([retried]); - const chunkId = "rec_upload_chunk_race:1"; - const chunkCacheRel = `chunks/${chunkId}.mp3`; - const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); - - await mkdir(path.dirname(chunkCachePath), { recursive: true }); - await writeFile(chunkCachePath, contents); - await upsertRecordingChunk({ - cachePath: chunkCacheRel, - createdAt: "2026-06-18T12:00:00.000Z", - durationSeconds: 60, - id: chunkId, - index: 1, - jobId: "job_chunk_race", - offsetSeconds: 0, - recordingId: retried.id, - status: "cached", - total: 1, - }); - - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - - const destination = await destinationStore.create({ - displayName: "Chunk Race Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: false, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Chunk race policy", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - cachePath: chunkCacheRel, - chunkId, - chunkIndex: 1, - destinationId: destination.id, - fileName: "chunk-1.mp3", - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - const updated = await recordingStore.find(retried.id); - - // Pre-fix the chunked reconcile blind-saved "uploaded" over the retry state. - assert.equal(updated?.status, "recording"); -}); - -test("G55-d: a stale chunked reconcile does not delete a re-captured chunk's cache", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "chunk-recapture-bytes"; - const retried = { - ...recording("rec_upload_chunk_recapture", contents), - status: "recording" as const, - }; - const recordingStore = memoryRecordingStore([retried]); - const chunkId = "rec_upload_chunk_recapture:1"; - const chunkCacheRel = `chunks/${chunkId}.mp3`; - const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); - - await mkdir(path.dirname(chunkCachePath), { recursive: true }); - await writeFile(chunkCachePath, contents); - await upsertRecordingChunk({ - cachePath: chunkCacheRel, - createdAt: "2026-06-18T12:00:00.000Z", - durationSeconds: 60, - id: chunkId, - index: 1, - jobId: "job_chunk_recapture", - offsetSeconds: 0, - recordingId: retried.id, - status: "cached", - total: 1, - }); - - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - const destination = await destinationStore.create({ - displayName: "Recapture Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: true, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Recapture delete policy", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - cachePath: chunkCacheRel, - chunkId, - chunkIndex: 1, - destinationId: destination.id, - fileName: "chunk-1.mp3", - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - - // Pre-fix the per-chunk deletion ran before the recording-status guard, so a - // stale pass (recording reset to `recording` by a retry) deleted the - // re-captured chunk's cache. The hoisted guard now skips the whole pass. - await assert.doesNotReject(readFile(chunkCachePath)); -}); - -test("upload runner routes expose status and run-now control", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - uploadRunner: runner, - }); - await enqueueRecordingUpload(recording("rec_upload_runner_route_test"), { - provider: "stub", - target: "stub://queue-only", - }); - - const before = await app.request("/api/v1/upload-runner"); - const actions = await app.request("/api/v1/upload-runner/actions"); - const run = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const actionPayload = (await actions.json()) as { - data: { actions: { run: { enabled: boolean; href?: string } } }; - }; - const payload = await run.json(); - const readEvents = await auditStore.list({ - action: "recordings.upload_runner.read.succeeded", - }); - const actionEvents = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - const events = await auditStore.list({ action: "recordings.upload_runner.run.succeeded" }); - - assert.equal(before.status, 200); - assert.equal(actions.status, 200); - assert.equal(actionPayload.data.actions.run.enabled, true); - assert.equal(actionPayload.data.actions.run.href, "/api/v1/upload-runner/run"); - assert.equal(run.status, 200); - assert.equal(payload.summary.succeeded, 1); - assert.equal(payload.data.lastSummary.succeeded, 1); - assert.equal(readEvents.length, 1); - assert.equal(readEvents[0]?.permission, "recording:read"); - assert.equal(readEvents[0]?.target.type, "upload_runner"); - assert.equal(readEvents[0]?.details.started, false); - assert.equal(readEvents[0]?.details.lastSummaryAttempted, 0); - assert.equal(actionEvents.length, 1); - assert.equal(actionEvents[0]?.permission, "recording:read"); - assert.equal(actionEvents[0]?.target.type, "upload_runner"); - assert.equal(actionEvents[0]?.details.started, false); - assert.equal(actionEvents[0]?.details.visibleActionCount, 2); - assert.equal(events.length, 1); - assert.equal(events[0]?.actor.id, "user_upload_runner_test"); -}); - -test("upload runner run route only processes queue items for scoped recordings", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - const visible = recording(`rec_upload_visible_${randomUUID()}`); - const hidden = recording(`rec_upload_hidden_${randomUUID()}`); - const visibleItem = await enqueueRecordingUpload(visible, { - provider: "stub", - target: "stub://visible", - }); - const hiddenItem = await enqueueRecordingUpload(hidden, { - provider: "stub", - target: "stub://hidden", - }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - scopedRecordings: async () => [visible], - uploadRunner: runner, - }); - - const response = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const body = (await response.json()) as { - summary: { attempted: number; items: Array<{ recordingId: string }>; succeeded: number }; - }; - const items = await listUploadQueueItems(); - const storedVisible = items.find((item) => item.id === visibleItem.id); - const storedHidden = items.find((item) => item.id === hiddenItem.id); - const itemEvents = await auditStore.list({ - action: "recordings.upload_queue.runner_item.succeeded", - }); - - assert.equal(response.status, 200); - assert.equal(body.summary.attempted, 1); - assert.equal(body.summary.succeeded, 1); - assert.deepEqual( - body.summary.items.map((item) => item.recordingId), - [visible.id], - ); - assert.equal(storedVisible?.status, "succeeded"); - assert.equal(storedHidden?.status, "queued"); - assert.equal(storedHidden?.attemptCount, 0); - assert.deepEqual( - itemEvents.map((event) => event.target.id), - [visible.id], - ); -}); - -test("upload runner status routes hide last-summary items outside scoped recordings", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - const visible = recording(`rec_upload_status_visible_${randomUUID()}`); - const hidden = recording(`rec_upload_status_hidden_${randomUUID()}`); - - await enqueueRecordingUpload(visible, { - provider: "stub", - target: "stub://visible", - }); - await enqueueRecordingUpload(hidden, { - provider: "stub", - target: "stub://hidden", - }); - await runner.runOnce(); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - scopedRecordings: async () => [visible], - uploadRunner: runner, - }); - - const statusResponse = await app.request("/api/v1/upload-runner"); - const actionsResponse = await app.request("/api/v1/upload-runner/actions"); - const statusBody = (await statusResponse.json()) as { - data: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; - }; - const actionsBody = (await actionsResponse.json()) as { - data: { - status: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; - }; - }; - const [readEvent] = await auditStore.list({ - action: "recordings.upload_runner.read.succeeded", - }); - const [actionEvent] = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - - assert.equal(statusResponse.status, 200); - assert.equal(actionsResponse.status, 200); - assert.ok((runner.status().lastSummary?.attempted ?? 0) > 1); - assert.equal(statusBody.data.lastSummary?.attempted, 1); - assert.deepEqual( - statusBody.data.lastSummary?.items.map((item) => item.recordingId), - [visible.id], - ); - assert.deepEqual(actionsBody.data.status.lastSummary, statusBody.data.lastSummary); - assert.equal(readEvent?.details.lastSummaryAttempted, 1); - assert.equal(readEvent?.details.lastSummaryItemCount, 1); - assert.equal(actionEvent?.details.lastSummaryAttempted, 1); - assert.equal(actionEvent?.details.lastSummaryItemCount, 1); -}); - -test("upload runner routes deny users without required permissions", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: viewer() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: denyMissingPermission(auditStore), - uploadRunner: runner, - }); - await enqueueRecordingUpload(recording("rec_upload_runner_denied_route_test"), { - provider: "stub", - target: "stub://queue-only", - }); - - const readResponse = await app.request("/api/v1/upload-runner"); - const actionsResponse = await app.request("/api/v1/upload-runner/actions"); - const runResponse = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const status = runner.status(); - const events = await auditStore.list({ outcome: "denied" }); - - assert.deepEqual( - [readResponse.status, actionsResponse.status, runResponse.status], - [403, 403, 403], - ); - assert.equal(status.lastSummary, undefined); - assert.deepEqual( - Object.fromEntries(events.map((event) => [event.action, event.permission]).sort()), - { - "recordings.upload_runner.actions.read": "recording:read", - "recordings.upload_runner.read": "recording:read", - "recordings.upload_runner.run": "recording:control", - }, - ); - assert.ok(events.every((event) => event.reason === "missing_permission")); - assert.ok(events.every((event) => event.target.type === "upload_runner")); -}); - -test("upload runner action summary reports missing control permission", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: viewer(["recording:read"]) }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - uploadRunner: runner, - }); - - const response = await app.request("/api/v1/upload-runner/actions"); - const body = (await response.json()) as { - data: { actions: { run: { enabled: boolean; reason?: string } } }; - }; - const [event] = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - - assert.equal(response.status, 200); - assert.equal(body.data.actions.run.enabled, false); - assert.equal(body.data.actions.run.reason, "missing_permission"); - assert.equal(event?.outcome, "succeeded"); - assert.equal(event?.details.visibleActionCount, 2); -}); - -const allow: RequirePermission = () => async (_c, next) => { - await next(); -}; - -function denyMissingPermission(auditStore: ReturnType): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: viewer() }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: ["viewer"], - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function user(): CurrentUser { - return { - email: "upload-runner@example.com", - groups: [], - id: "user_upload_runner_test", - name: "Upload Runner Test", - permissions: ["recording:control", "recording:read"], - provider: "local", - resourceGrants: [], - roles: ["admin"], - }; -} - -function viewer(permissions: CurrentUser["permissions"] = []): CurrentUser { - return { - email: "upload-runner-viewer@example.com", - groups: [], - id: "user_upload_runner_viewer_test", - name: "Upload Runner Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function recording(id = "rec_upload_runner_test", contents?: string): RecordingSummary { - return { - cachePath: `scheduled/${id}.mp3`, - cached: true, - checksum: contents ? sha256Prefixed(contents) : `sha256:${id}`, - durationSeconds: 900, - folder: "Meetings/2026", - healthStatus: "healthy", - id, - name: "Council Meeting", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "schedule", - status: "cached", - tags: ["council"], - }; -} - -async function cacheRecording(id: string, contents: string) { - const cachePath = path.join(runnerRoot, "cache", "scheduled", `${id}.mp3`); - - await mkdir(path.dirname(cachePath), { recursive: true }); - await writeFile(cachePath, contents); - - return cachePath; -} - -function sha256Prefixed(contents: string) { - return `sha256:${createHash("sha256").update(contents).digest("hex")}`; -} - -function throwingSmbClient() { - return { - async close() {}, - async connect() { - throw new Error("smb_connect_failed"); - }, - async mkdir() {}, - async readFile() { - return Buffer.alloc(0); - }, - async writeFile() {}, - }; -} - -function fakeSmbClient() { - const files = new Map(); - const dirs: string[] = []; - - return { - client: { - async close() {}, - async connect() {}, - async mkdir(targetPath: string) { - dirs.push(targetPath); - }, - async readFile(targetPath: string) { - const data = files.get(targetPath); - - if (!data) { - const error = new Error("ENOENT") as Error & { code: string }; - error.code = "ENOENT"; - throw error; - } - - return data; - }, - async writeFile(targetPath: string, data: Buffer | string) { - files.set(targetPath, Buffer.from(data)); - }, - }, - dirs, - files, - }; -} From 124c728da45c86780cbe454d7cb8363e0902d333 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 21:12:52 +0500 Subject: [PATCH 6/9] Split fake-controller smoke scripts under the LOC budget Break the three oversized fake-controller smoke files into cohesive modules: - health (923) -> a re-export facade over -health-system (system/node lifecycle scenarios) and -health-meters (meter/monitor data-plane scenarios); - recovery (961) -> the upload-boundary orchestrator + its four scenarios, with the disk/device scenarios and their df-command writers moved to -recovery-disk; - the smoke entry (924) -> pure builders/diagnostics (createObserved, retention builders, recorderCachePoliciesForScenario, createJob via makeCreateJob, fakeDfCommandPath, daemonScenarioDiagnostics) moved to -scenario-helpers. Function bodies are byte-identical; public export surfaces are unchanged so importers keep working. All modules import cleanly and check:loc passes. Co-Authored-By: Claude Opus 4.8 --- ...nt-fake-controller-smoke-health-meters.mjs | 431 ++++++++ ...nt-fake-controller-smoke-health-system.mjs | 495 ++++++++++ .../agent-fake-controller-smoke-health.mjs | 928 +----------------- ...nt-fake-controller-smoke-recovery-disk.mjs | 484 +++++++++ .../agent-fake-controller-smoke-recovery.mjs | 494 +--------- ...fake-controller-smoke-scenario-helpers.mjs | 143 +++ scripts/agent-fake-controller-smoke.mjs | 145 +-- 7 files changed, 1576 insertions(+), 1544 deletions(-) create mode 100644 scripts/agent-fake-controller-smoke-health-meters.mjs create mode 100644 scripts/agent-fake-controller-smoke-health-system.mjs create mode 100644 scripts/agent-fake-controller-smoke-recovery-disk.mjs create mode 100644 scripts/agent-fake-controller-smoke-scenario-helpers.mjs diff --git a/scripts/agent-fake-controller-smoke-health-meters.mjs b/scripts/agent-fake-controller-smoke-health-meters.mjs new file mode 100644 index 00000000..45ebbc2a --- /dev/null +++ b/scripts/agent-fake-controller-smoke-health-meters.mjs @@ -0,0 +1,431 @@ +import path from "node:path"; + +import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runMeterXrunScenario({ + address, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, + xrunMeterCommand, +}) { + const stateFile = path.join(smokeRoot, "meter-xrun-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-xrun-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ jobs: [], observed, scenario: { expectSuccess: true, name: "meter-xrun" } }); + const child = spawnDaemonAgent({ + address, + captureCommand: xrunMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find((event) => event.type === "agent.meter.xrun"); + + invariant(syncedEvent?.severity === "warning", "meter xrun event was not warning"); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter xrun did not report synthetic fallback", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.xrun"), + "meter xrun event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterFrameSyncRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-frame-sync-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-frame-sync-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + meterFrameFailuresRemaining: 1, + name: "meter-frame-sync-recovery", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_failed") && + observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_recovered") && + observed.meterFrameFailures === 1 && + observed.meterFrames >= 1, + 20_000, + () => + `meterFailures=${observed.meterFrameFailures} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter_frame.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter_frame.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "meter-frame sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected meter frame with 503"), + "meter-frame sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.interfaceId === "iface_default_capture", + "meter-frame sync failure did not preserve interface id", + ); + invariant( + failedEvent?.details?.channelCount === 2, + "meter-frame sync failure did not preserve channel count", + ); + invariant(recoveredEvent?.severity === "info", "meter-frame sync recovery was not info"); + invariant( + recoveredEvent?.details?.interfaceId === failedEvent.details.interfaceId, + "meter-frame sync recovery did not preserve interface id", + ); + invariant( + recoveredEvent?.details?.channelCount === failedEvent.details.channelCount, + "meter-frame sync recovery did not preserve channel count", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_failed"), + "meter-frame sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_recovered"), + "meter-frame sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterDeviceUnavailableScenario({ + address, + createObserved, + deviceUnavailableMeterCommand, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-device-unavailable-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-device-unavailable-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-device-unavailable" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: deviceUnavailableMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.device_unavailable", + ); + + invariant( + syncedEvent?.severity === "critical", + "meter device unavailable event was not critical", + ); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter device unavailable did not report synthetic fallback", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.device_unavailable"), + "meter device unavailable event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterCaptureFailedScenario({ + address, + captureFailedMeterCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-capture-failed-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-capture-failed-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-capture-failed" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: captureFailedMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.capture_failed") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.capture_failed", + ); + + invariant(syncedEvent?.severity === "warning", "meter capture failure was not warning"); + invariant( + syncedEvent?.details?.classification === "capture_failed", + "meter capture failure did not preserve generic classification", + ); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter capture failure did not report synthetic fallback", + ); + invariant( + !observed.healthEvents.some((event) => event.type === "agent.meter.xrun"), + "generic meter capture failure was misclassified as xrun", + ); + invariant( + !observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable"), + "generic meter capture failure was misclassified as device unavailable", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.capture_failed"), + "meter capture failure event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterRecoveryScenario({ + address, + createObserved, + recoveringMeterCommand, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-recovery" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: recoveringMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && + observed.healthEvents.some((event) => event.type === "agent.meter.capture_recovered") && + observed.meterFrames >= 2, + 20_000, + () => + `meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const recoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.capture_recovered", + ); + + invariant(recoveryEvent?.severity === "info", "meter recovery event was not info"); + invariant( + recoveryEvent?.details?.previousType === "agent.meter.xrun", + "meter recovery did not preserve previous xrun type", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.capture_recovered"), + "meter recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMonitorChunkRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "monitor-chunk-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "monitor-chunk-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + monitorChunkFailuresRemaining: 1, + name: "monitor-chunk-recovery", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some( + (event) => event.type === "agent.listen_monitor.chunk_sync_failed", + ) && + observed.healthEvents.some( + (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", + ) && + observed.monitorChunkFailures === 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `monitorFailures=${observed.monitorChunkFailures} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.listen_monitor.chunk_sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", + ); + const uploadedChunk = observed.monitorChunks.at(0); + + invariant(failedEvent?.severity === "warning", "monitor chunk failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected monitor chunk with 503"), + "monitor chunk failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.contentType === "audio/wav", + "monitor chunk failure did not preserve content type", + ); + invariant( + failedEvent?.details?.durationMs === 1000, + "monitor chunk failure did not preserve duration", + ); + invariant( + failedEvent?.details?.monitorBytes > 44, + "monitor chunk failure did not preserve monitor bytes", + ); + invariant( + failedEvent?.details?.interfaceId === "iface_default_capture", + "monitor chunk failure did not preserve interface id", + ); + invariant(recoveredEvent?.severity === "info", "monitor chunk recovery was not info"); + invariant( + recoveredEvent?.details?.contentType === "audio/wav", + "monitor chunk recovery did not preserve content type", + ); + invariant( + recoveredEvent?.details?.monitorBytes > 44, + "monitor chunk recovery did not preserve monitor bytes", + ); + invariant( + uploadedChunk?.contentType === "audio/wav", + "fake controller did not receive monitor chunk content type", + ); + invariant(uploadedChunk?.durationMs === "1000", "fake controller did not receive duration"); + invariant(uploadedChunk?.size > 44, "fake controller did not receive monitor bytes"); + invariant( + healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_failed"), + "monitor chunk failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_recovered"), + "monitor chunk recovery event was not written locally", + ); + setActiveScenario(undefined); +} diff --git a/scripts/agent-fake-controller-smoke-health-system.mjs b/scripts/agent-fake-controller-smoke-health-system.mjs new file mode 100644 index 00000000..4e82dcb4 --- /dev/null +++ b/scripts/agent-fake-controller-smoke-health-system.mjs @@ -0,0 +1,495 @@ +import path from "node:path"; + +import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runSystemHealthScenario({ + address, + captureCommand, + createObserved, + fakeDfPath, + fakeLoadavgPath, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "system-health-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "system-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "system-health", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + extraAgentArgs: [ + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + "--system-health-load-warning-per-core", + "0.01", + "--system-health-load-critical-per-core", + "1000", + "--system-health-loadavg-path", + fakeLoadavgPath, + ], + extraEnv: { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.system.disk_pressure") && + observed.healthEvents.some((event) => event.type === "agent.system.cpu_pressure") && + observed.healthEvents.some((event) => event.type === "agent.system.disk_recovered") && + observed.healthEvents.some((event) => event.type === "agent.system.cpu_recovered") && + observed.nodeHeartbeats >= 2 && + observed.inventoryReconciles >= 1 && + observed.meterFrames >= 2, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} inventory=${observed.inventoryReconciles} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.disk_pressure", + ); + const cpuEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.cpu_pressure", + ); + const diskRecoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.disk_recovered", + ); + const cpuRecoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.cpu_recovered", + ); + const localEvent = healthLogEvents.find((event) => event.type === "agent.system.disk_pressure"); + const localCpuEvent = healthLogEvents.find( + (event) => event.type === "agent.system.cpu_pressure", + ); + const localDiskRecoveryEvent = healthLogEvents.find( + (event) => event.type === "agent.system.disk_recovered", + ); + const localCpuRecoveryEvent = healthLogEvents.find( + (event) => event.type === "agent.system.cpu_recovered", + ); + + invariant(syncedEvent?.severity === "warning", "system health disk event was not warning"); + invariant( + syncedEvent?.details?.usedPercent === 90, + "system health disk event did not use fake df pressure", + ); + invariant(cpuEvent?.severity === "warning", "system health CPU event was not warning"); + invariant( + cpuEvent?.details?.loadAverageOneMinute === 12.5, + "system health CPU event did not use fake loadavg pressure", + ); + invariant( + cpuEvent?.details?.loadPerCore >= 0.1, + "system health CPU event did not preserve per-core pressure", + ); + invariant(diskRecoveryEvent?.severity === "info", "system health disk recovery was not info"); + invariant( + diskRecoveryEvent?.details?.usedPercent === 10, + "system health disk recovery did not use fake df recovery", + ); + invariant(cpuRecoveryEvent?.severity === "info", "system health CPU recovery was not info"); + invariant( + cpuRecoveryEvent?.details?.loadAverageOneMinute === 0, + "system health CPU recovery did not use fake loadavg recovery", + ); + invariant(localEvent, "system health disk pressure event was not written locally"); + invariant(localCpuEvent, "system health CPU pressure event was not written locally"); + invariant(localDiskRecoveryEvent, "system health disk recovery event was not written locally"); + invariant(localCpuRecoveryEvent, "system health CPU recovery event was not written locally"); + setActiveScenario(undefined); +} + +export async function runClockSkewRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "clock-skew-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "clock-skew-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "clock-skew-recovery", + nodeHeartbeatDateHeaders: [ + () => new Date(Date.now() + 10_000).toUTCString(), + () => new Date().toUTCString(), + ], + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.system.clock_skew") && + observed.healthEvents.some( + (event) => event.type === "agent.system.clock_skew_recovered", + ) && + observed.nodeHeartbeats >= 2, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const skewEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.clock_skew", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.clock_skew_recovered", + ); + const localSkewEvent = healthLogEvents.find( + (event) => event.type === "agent.system.clock_skew", + ); + const localRecoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.system.clock_skew_recovered", + ); + + invariant(skewEvent?.severity === "warning", "clock skew event was not warning"); + invariant( + skewEvent?.details?.absoluteSkewSeconds > 5, + "clock skew event did not preserve warning skew evidence", + ); + invariant( + skewEvent?.details?.warningSeconds === 5, + "clock skew event did not include warning threshold", + ); + invariant(recoveredEvent?.severity === "info", "clock skew recovery was not info"); + invariant( + recoveredEvent?.details?.absoluteSkewSeconds <= 2, + "clock skew recovery did not preserve recovered skew evidence", + ); + invariant(localSkewEvent, "clock skew event was not written locally"); + invariant(localRecoveredEvent, "clock skew recovery event was not written locally"); + setActiveScenario(undefined); +} + +export async function runNodeHeartbeatRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "node-heartbeat-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "node-heartbeat-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "node-heartbeat-recovery", + nodeHeartbeatFailuresRemaining: 1, + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed") && + observed.healthEvents.some( + (event) => event.type === "agent.node_heartbeat.sync_recovered", + ) && + observed.nodeHeartbeatFailures === 1 && + observed.nodeHeartbeats >= 1, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} heartbeatFailures=${observed.nodeHeartbeatFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_heartbeat.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_heartbeat.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "node heartbeat sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected node heartbeat with 503"), + "node heartbeat sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.nodeId === "node_fake_controller_smoke", + "node heartbeat sync failure did not preserve node id", + ); + invariant( + failedEvent?.details?.alias === "Local Recorder Node", + "node heartbeat sync failure did not preserve node alias", + ); + invariant( + failedEvent?.details?.status === "online", + "node heartbeat sync failure did not preserve node status", + ); + invariant( + failedEvent?.details?.interfaceCount >= 1, + "node heartbeat sync failure did not preserve interface count", + ); + invariant( + Array.isArray(failedEvent?.details?.audioBackends), + "node heartbeat sync failure did not preserve audio backends", + ); + invariant(recoveredEvent?.severity === "info", "node heartbeat sync recovery was not info"); + invariant( + recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, + "node heartbeat sync recovery did not preserve node id", + ); + invariant( + recoveredEvent?.details?.interfaceCount >= 1, + "node heartbeat sync recovery did not preserve interface count", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed"), + "node heartbeat sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_recovered"), + "node heartbeat sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runNodeConfigRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "node-config-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "node-config-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "node-config-recovery", + nodeConfigFailuresRemaining: 1, + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.node_config.sync_failed") && + observed.healthEvents.some((event) => event.type === "agent.node_config.sync_recovered") && + observed.nodeConfigFailures === 1 && + observed.configReads >= 2, + 20_000, + () => + `configReads=${observed.configReads} configFailures=${observed.nodeConfigFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_config.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_config.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "node config sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes( + "controller rejected node config request with 503", + ), + "node config sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.nodeId === "node_fake_controller_smoke", + "node config sync failure did not preserve node id", + ); + invariant( + failedEvent?.details?.maxConcurrentRecordings === null, + "node config sync failure should not invent capacity evidence", + ); + invariant(recoveredEvent?.severity === "info", "node config sync recovery was not info"); + invariant( + recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, + "node config sync recovery did not preserve node id", + ); + invariant( + recoveredEvent?.details?.maxConcurrentRecordings === 1, + "node config sync recovery did not preserve capacity", + ); + invariant( + recoveredEvent?.details?.recorderCachePolicyCount === 0, + "node config sync recovery did not preserve recorder-cache policy count", + ); + invariant( + recoveredEvent?.details?.audioDefaultsConfigured === false, + "node config sync recovery did not preserve audio-default state", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_config.sync_failed"), + "node config sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_config.sync_recovered"), + "node config sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +export async function runAudioBackendRecoveryScenario({ + address, + captureCommand, + createObserved, + fakeArecordCommand, + fakeArecordPath, + fakeProcAsoundPcmPath, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "audio-backend-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "audio-backend-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "audio-backend-recovery" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + extraAgentArgs: [ + "--inventory-arecord-command", + fakeArecordCommand, + "--inventory-proc-asound-pcm-path", + fakeProcAsoundPcmPath, + ], + extraEnv: { + PATH: `${fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.audio_backend.unavailable") && + observed.healthEvents.some((event) => event.type === "agent.audio_backend.recovered") && + observed.nodeHeartbeats >= 1 && + observed.meterFrames >= 1, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const unavailableEvent = observed.healthEvents.find( + (event) => event.type === "agent.audio_backend.unavailable", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.audio_backend.recovered", + ); + + invariant(unavailableEvent?.severity === "warning", "audio backend unavailable was not warning"); + invariant( + unavailableEvent?.details?.availableInterfaces === 0, + "audio backend unavailable did not report zero available interfaces", + ); + invariant( + unavailableEvent?.details?.interfaces === 1, + "audio backend unavailable did not preserve fallback interface count", + ); + invariant( + unavailableEvent?.details?.audioBackends?.includes("unknown"), + "audio backend unavailable did not preserve unknown backend evidence", + ); + invariant(recoveredEvent?.severity === "info", "audio backend recovery was not info"); + invariant( + recoveredEvent?.details?.availableInterfaces === 1, + "audio backend recovery did not report available interface", + ); + invariant( + recoveredEvent?.details?.audioBackends?.includes("alsa"), + "audio backend recovery did not preserve recovered ALSA backend", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.audio_backend.unavailable"), + "audio backend unavailable event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.audio_backend.recovered"), + "audio backend recovery event was not written locally", + ); + setActiveScenario(undefined); +} diff --git a/scripts/agent-fake-controller-smoke-health.mjs b/scripts/agent-fake-controller-smoke-health.mjs index dafd5676..ef807b99 100644 --- a/scripts/agent-fake-controller-smoke-health.mjs +++ b/scripts/agent-fake-controller-smoke-health.mjs @@ -1,923 +1,5 @@ -import path from "node:path"; - -import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; - -export async function runSystemHealthScenario({ - address, - captureCommand, - createObserved, - fakeDfPath, - fakeLoadavgPath, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "system-health-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "system-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "system-health", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - extraAgentArgs: [ - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - "--system-health-load-warning-per-core", - "0.01", - "--system-health-load-critical-per-core", - "1000", - "--system-health-loadavg-path", - fakeLoadavgPath, - ], - extraEnv: { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.system.disk_pressure") && - observed.healthEvents.some((event) => event.type === "agent.system.cpu_pressure") && - observed.healthEvents.some((event) => event.type === "agent.system.disk_recovered") && - observed.healthEvents.some((event) => event.type === "agent.system.cpu_recovered") && - observed.nodeHeartbeats >= 2 && - observed.inventoryReconciles >= 1 && - observed.meterFrames >= 2, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} inventory=${observed.inventoryReconciles} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.disk_pressure", - ); - const cpuEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.cpu_pressure", - ); - const diskRecoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.disk_recovered", - ); - const cpuRecoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.cpu_recovered", - ); - const localEvent = healthLogEvents.find((event) => event.type === "agent.system.disk_pressure"); - const localCpuEvent = healthLogEvents.find( - (event) => event.type === "agent.system.cpu_pressure", - ); - const localDiskRecoveryEvent = healthLogEvents.find( - (event) => event.type === "agent.system.disk_recovered", - ); - const localCpuRecoveryEvent = healthLogEvents.find( - (event) => event.type === "agent.system.cpu_recovered", - ); - - invariant(syncedEvent?.severity === "warning", "system health disk event was not warning"); - invariant( - syncedEvent?.details?.usedPercent === 90, - "system health disk event did not use fake df pressure", - ); - invariant(cpuEvent?.severity === "warning", "system health CPU event was not warning"); - invariant( - cpuEvent?.details?.loadAverageOneMinute === 12.5, - "system health CPU event did not use fake loadavg pressure", - ); - invariant( - cpuEvent?.details?.loadPerCore >= 0.1, - "system health CPU event did not preserve per-core pressure", - ); - invariant(diskRecoveryEvent?.severity === "info", "system health disk recovery was not info"); - invariant( - diskRecoveryEvent?.details?.usedPercent === 10, - "system health disk recovery did not use fake df recovery", - ); - invariant(cpuRecoveryEvent?.severity === "info", "system health CPU recovery was not info"); - invariant( - cpuRecoveryEvent?.details?.loadAverageOneMinute === 0, - "system health CPU recovery did not use fake loadavg recovery", - ); - invariant(localEvent, "system health disk pressure event was not written locally"); - invariant(localCpuEvent, "system health CPU pressure event was not written locally"); - invariant(localDiskRecoveryEvent, "system health disk recovery event was not written locally"); - invariant(localCpuRecoveryEvent, "system health CPU recovery event was not written locally"); - setActiveScenario(undefined); -} - -export async function runClockSkewRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "clock-skew-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "clock-skew-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "clock-skew-recovery", - nodeHeartbeatDateHeaders: [ - () => new Date(Date.now() + 10_000).toUTCString(), - () => new Date().toUTCString(), - ], - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.system.clock_skew") && - observed.healthEvents.some( - (event) => event.type === "agent.system.clock_skew_recovered", - ) && - observed.nodeHeartbeats >= 2, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const skewEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.clock_skew", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.clock_skew_recovered", - ); - const localSkewEvent = healthLogEvents.find( - (event) => event.type === "agent.system.clock_skew", - ); - const localRecoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.system.clock_skew_recovered", - ); - - invariant(skewEvent?.severity === "warning", "clock skew event was not warning"); - invariant( - skewEvent?.details?.absoluteSkewSeconds > 5, - "clock skew event did not preserve warning skew evidence", - ); - invariant( - skewEvent?.details?.warningSeconds === 5, - "clock skew event did not include warning threshold", - ); - invariant(recoveredEvent?.severity === "info", "clock skew recovery was not info"); - invariant( - recoveredEvent?.details?.absoluteSkewSeconds <= 2, - "clock skew recovery did not preserve recovered skew evidence", - ); - invariant(localSkewEvent, "clock skew event was not written locally"); - invariant(localRecoveredEvent, "clock skew recovery event was not written locally"); - setActiveScenario(undefined); -} - -export async function runMeterXrunScenario({ - address, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, - xrunMeterCommand, -}) { - const stateFile = path.join(smokeRoot, "meter-xrun-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-xrun-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ jobs: [], observed, scenario: { expectSuccess: true, name: "meter-xrun" } }); - const child = spawnDaemonAgent({ - address, - captureCommand: xrunMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find((event) => event.type === "agent.meter.xrun"); - - invariant(syncedEvent?.severity === "warning", "meter xrun event was not warning"); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter xrun did not report synthetic fallback", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.xrun"), - "meter xrun event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterFrameSyncRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-frame-sync-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-frame-sync-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - meterFrameFailuresRemaining: 1, - name: "meter-frame-sync-recovery", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_failed") && - observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_recovered") && - observed.meterFrameFailures === 1 && - observed.meterFrames >= 1, - 20_000, - () => - `meterFailures=${observed.meterFrameFailures} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter_frame.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter_frame.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "meter-frame sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected meter frame with 503"), - "meter-frame sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.interfaceId === "iface_default_capture", - "meter-frame sync failure did not preserve interface id", - ); - invariant( - failedEvent?.details?.channelCount === 2, - "meter-frame sync failure did not preserve channel count", - ); - invariant(recoveredEvent?.severity === "info", "meter-frame sync recovery was not info"); - invariant( - recoveredEvent?.details?.interfaceId === failedEvent.details.interfaceId, - "meter-frame sync recovery did not preserve interface id", - ); - invariant( - recoveredEvent?.details?.channelCount === failedEvent.details.channelCount, - "meter-frame sync recovery did not preserve channel count", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_failed"), - "meter-frame sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_recovered"), - "meter-frame sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterDeviceUnavailableScenario({ - address, - createObserved, - deviceUnavailableMeterCommand, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-device-unavailable-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-device-unavailable-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-device-unavailable" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: deviceUnavailableMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.device_unavailable", - ); - - invariant( - syncedEvent?.severity === "critical", - "meter device unavailable event was not critical", - ); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter device unavailable did not report synthetic fallback", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.device_unavailable"), - "meter device unavailable event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterCaptureFailedScenario({ - address, - captureFailedMeterCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-capture-failed-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-capture-failed-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-capture-failed" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: captureFailedMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.capture_failed") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.capture_failed", - ); - - invariant(syncedEvent?.severity === "warning", "meter capture failure was not warning"); - invariant( - syncedEvent?.details?.classification === "capture_failed", - "meter capture failure did not preserve generic classification", - ); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter capture failure did not report synthetic fallback", - ); - invariant( - !observed.healthEvents.some((event) => event.type === "agent.meter.xrun"), - "generic meter capture failure was misclassified as xrun", - ); - invariant( - !observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable"), - "generic meter capture failure was misclassified as device unavailable", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.capture_failed"), - "meter capture failure event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterRecoveryScenario({ - address, - createObserved, - recoveringMeterCommand, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-recovery" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: recoveringMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && - observed.healthEvents.some((event) => event.type === "agent.meter.capture_recovered") && - observed.meterFrames >= 2, - 20_000, - () => - `meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const recoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.capture_recovered", - ); - - invariant(recoveryEvent?.severity === "info", "meter recovery event was not info"); - invariant( - recoveryEvent?.details?.previousType === "agent.meter.xrun", - "meter recovery did not preserve previous xrun type", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.capture_recovered"), - "meter recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMonitorChunkRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "monitor-chunk-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "monitor-chunk-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - monitorChunkFailuresRemaining: 1, - name: "monitor-chunk-recovery", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some( - (event) => event.type === "agent.listen_monitor.chunk_sync_failed", - ) && - observed.healthEvents.some( - (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", - ) && - observed.monitorChunkFailures === 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `monitorFailures=${observed.monitorChunkFailures} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.listen_monitor.chunk_sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", - ); - const uploadedChunk = observed.monitorChunks.at(0); - - invariant(failedEvent?.severity === "warning", "monitor chunk failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected monitor chunk with 503"), - "monitor chunk failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.contentType === "audio/wav", - "monitor chunk failure did not preserve content type", - ); - invariant( - failedEvent?.details?.durationMs === 1000, - "monitor chunk failure did not preserve duration", - ); - invariant( - failedEvent?.details?.monitorBytes > 44, - "monitor chunk failure did not preserve monitor bytes", - ); - invariant( - failedEvent?.details?.interfaceId === "iface_default_capture", - "monitor chunk failure did not preserve interface id", - ); - invariant(recoveredEvent?.severity === "info", "monitor chunk recovery was not info"); - invariant( - recoveredEvent?.details?.contentType === "audio/wav", - "monitor chunk recovery did not preserve content type", - ); - invariant( - recoveredEvent?.details?.monitorBytes > 44, - "monitor chunk recovery did not preserve monitor bytes", - ); - invariant( - uploadedChunk?.contentType === "audio/wav", - "fake controller did not receive monitor chunk content type", - ); - invariant(uploadedChunk?.durationMs === "1000", "fake controller did not receive duration"); - invariant(uploadedChunk?.size > 44, "fake controller did not receive monitor bytes"); - invariant( - healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_failed"), - "monitor chunk failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_recovered"), - "monitor chunk recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runNodeHeartbeatRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "node-heartbeat-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "node-heartbeat-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "node-heartbeat-recovery", - nodeHeartbeatFailuresRemaining: 1, - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed") && - observed.healthEvents.some( - (event) => event.type === "agent.node_heartbeat.sync_recovered", - ) && - observed.nodeHeartbeatFailures === 1 && - observed.nodeHeartbeats >= 1, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} heartbeatFailures=${observed.nodeHeartbeatFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_heartbeat.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_heartbeat.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "node heartbeat sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected node heartbeat with 503"), - "node heartbeat sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.nodeId === "node_fake_controller_smoke", - "node heartbeat sync failure did not preserve node id", - ); - invariant( - failedEvent?.details?.alias === "Local Recorder Node", - "node heartbeat sync failure did not preserve node alias", - ); - invariant( - failedEvent?.details?.status === "online", - "node heartbeat sync failure did not preserve node status", - ); - invariant( - failedEvent?.details?.interfaceCount >= 1, - "node heartbeat sync failure did not preserve interface count", - ); - invariant( - Array.isArray(failedEvent?.details?.audioBackends), - "node heartbeat sync failure did not preserve audio backends", - ); - invariant(recoveredEvent?.severity === "info", "node heartbeat sync recovery was not info"); - invariant( - recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, - "node heartbeat sync recovery did not preserve node id", - ); - invariant( - recoveredEvent?.details?.interfaceCount >= 1, - "node heartbeat sync recovery did not preserve interface count", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed"), - "node heartbeat sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_recovered"), - "node heartbeat sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runNodeConfigRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "node-config-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "node-config-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "node-config-recovery", - nodeConfigFailuresRemaining: 1, - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.node_config.sync_failed") && - observed.healthEvents.some((event) => event.type === "agent.node_config.sync_recovered") && - observed.nodeConfigFailures === 1 && - observed.configReads >= 2, - 20_000, - () => - `configReads=${observed.configReads} configFailures=${observed.nodeConfigFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_config.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_config.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "node config sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes( - "controller rejected node config request with 503", - ), - "node config sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.nodeId === "node_fake_controller_smoke", - "node config sync failure did not preserve node id", - ); - invariant( - failedEvent?.details?.maxConcurrentRecordings === null, - "node config sync failure should not invent capacity evidence", - ); - invariant(recoveredEvent?.severity === "info", "node config sync recovery was not info"); - invariant( - recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, - "node config sync recovery did not preserve node id", - ); - invariant( - recoveredEvent?.details?.maxConcurrentRecordings === 1, - "node config sync recovery did not preserve capacity", - ); - invariant( - recoveredEvent?.details?.recorderCachePolicyCount === 0, - "node config sync recovery did not preserve recorder-cache policy count", - ); - invariant( - recoveredEvent?.details?.audioDefaultsConfigured === false, - "node config sync recovery did not preserve audio-default state", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_config.sync_failed"), - "node config sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_config.sync_recovered"), - "node config sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -export async function runAudioBackendRecoveryScenario({ - address, - captureCommand, - createObserved, - fakeArecordCommand, - fakeArecordPath, - fakeProcAsoundPcmPath, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "audio-backend-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "audio-backend-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "audio-backend-recovery" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - extraAgentArgs: [ - "--inventory-arecord-command", - fakeArecordCommand, - "--inventory-proc-asound-pcm-path", - fakeProcAsoundPcmPath, - ], - extraEnv: { - PATH: `${fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.audio_backend.unavailable") && - observed.healthEvents.some((event) => event.type === "agent.audio_backend.recovered") && - observed.nodeHeartbeats >= 1 && - observed.meterFrames >= 1, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const unavailableEvent = observed.healthEvents.find( - (event) => event.type === "agent.audio_backend.unavailable", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.audio_backend.recovered", - ); - - invariant(unavailableEvent?.severity === "warning", "audio backend unavailable was not warning"); - invariant( - unavailableEvent?.details?.availableInterfaces === 0, - "audio backend unavailable did not report zero available interfaces", - ); - invariant( - unavailableEvent?.details?.interfaces === 1, - "audio backend unavailable did not preserve fallback interface count", - ); - invariant( - unavailableEvent?.details?.audioBackends?.includes("unknown"), - "audio backend unavailable did not preserve unknown backend evidence", - ); - invariant(recoveredEvent?.severity === "info", "audio backend recovery was not info"); - invariant( - recoveredEvent?.details?.availableInterfaces === 1, - "audio backend recovery did not report available interface", - ); - invariant( - recoveredEvent?.details?.audioBackends?.includes("alsa"), - "audio backend recovery did not preserve recovered ALSA backend", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.audio_backend.unavailable"), - "audio backend unavailable event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.audio_backend.recovered"), - "audio backend recovery event was not written locally", - ); - setActiveScenario(undefined); -} +// Fake-controller health/recovery scenarios. The scenario functions live in two +// cohesive sibling modules (system/node lifecycle vs. meter/monitor data plane); +// this module re-exports them so the smoke entry point keeps one import surface. +export * from "./agent-fake-controller-smoke-health-system.mjs"; +export * from "./agent-fake-controller-smoke-health-meters.mjs"; diff --git a/scripts/agent-fake-controller-smoke-recovery-disk.mjs b/scripts/agent-fake-controller-smoke-recovery-disk.mjs new file mode 100644 index 00000000..d83c3b1e --- /dev/null +++ b/scripts/agent-fake-controller-smoke-recovery-disk.mjs @@ -0,0 +1,484 @@ +import { existsSync, readFileSync } from "node:fs"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { spawnDaemonAgent } from "./agent-fake-controller-smoke-agent.mjs"; +import { + writeDeviceAssertCaptureCommand, + writeRenumberedAudioInventoryFixtures, +} from "./agent-fake-controller-smoke-support.mjs"; +import { fileExists, invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runDiskPreflightRecoveryScenario({ + address, + agentContext, + captureCommand, + createJob, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + const stateFile = path.join(smokeRoot, "disk-preflight-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "disk-preflight-recovery-health-events.jsonl"); + const manifestFile = path.join(smokeRoot, "disk-preflight-recovery-manifest.json"); + const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); + const staleRawPath = path.join(cacheRoot, "rec_fake_controller_stale.raw.wav"); + const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_stale.mp3"); + const fakeDfPath = await writeDiskPreflightDfCommand(smokeRoot); + const retention = { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-disk-preflight-recovery-smoke", + }; + const job = createJob({ + captureChannels: 4, + durationSeconds: 2, + expectSuccess: true, + jobId: "job_fake_controller_disk_preflight_recovery", + name: "disk-preflight-recovery", + outputFileName: "rec_fake_controller_disk_preflight_recovery.mp3", + recordingId: "rec_fake_controller_disk_preflight_recovery", + recorderCacheRetention: retention, + }); + const observed = createObserved(); + + await mkdir(cacheRoot, { recursive: true }); + await writeFile(staleRawPath, Buffer.alloc(128, 1)); + await writeFile(staleOutputPath, Buffer.alloc(256, 2)); + await writeFile( + manifestFile, + JSON.stringify( + { + entries: [ + { + outputPath: staleOutputPath, + policyId: retention.policyId, + rawOutputPath: staleRawPath, + recordingId: "rec_fake_controller_stale", + }, + ], + version: 1, + }, + null, + 2, + ), + ); + + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "disk-preflight-recovery", + recorderCachePolicySequence: [[], [retention]], + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--recorder-cache-manifest-file", + manifestFile, + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.disk_space_recovered", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const cleanupEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_cleanup_attempted", + ); + const recoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_recovered", + ); + + invariant(job.status === "completed", "disk preflight recovery did not complete the job"); + invariant(state.status === "completed", "disk preflight recovery state did not complete"); + invariant(observed.cacheUploads.length === 1, "disk preflight recovery did not upload once"); + invariant(cleanupEvent?.details?.deleted === 1, "disk preflight cleanup did not delete cache"); + invariant( + cleanupEvent.details?.items?.[0]?.reason === "min_free_disk", + "disk preflight cleanup did not use min-free policy", + ); + invariant( + recoveredEvent?.details?.freeBytes > recoveredEvent?.details?.initialFreeBytes, + "disk preflight recovery did not record improved free space", + ); + invariant(!(await fileExists(staleRawPath)), "disk preflight cleanup left stale raw cache"); + invariant(!(await fileExists(staleOutputPath)), "disk preflight cleanup left stale output cache"); + setActiveScenario(undefined); +} + +export async function runRuntimeDiskRecoveryScenario({ + address, + agentContext, + captureCommand, + createJob, + createObserved, + nodeId: _nodeId, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + const stateFile = path.join(smokeRoot, "runtime-disk-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "runtime-disk-recovery-health-events.jsonl"); + const manifestFile = path.join(smokeRoot, "runtime-disk-recovery-manifest.json"); + const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); + const rawCapturePath = path.join( + cacheRoot, + "rec_fake_controller_runtime_disk_recovery.raw.wav", + ); + const staleRawPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.raw.wav"); + const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.mp3"); + const fakeDfPath = await writeRuntimeDiskRecoveryDfCommand( + smokeRoot, + rawCapturePath, + staleOutputPath, + ); + const retention = { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-runtime-disk-recovery-smoke", + }; + const job = createJob({ + durationSeconds: 2, + expectSuccess: true, + jobId: "job_fake_controller_runtime_disk_recovery", + name: "runtime-disk-recovery", + outputFileName: "rec_fake_controller_runtime_disk_recovery.mp3", + recordingId: "rec_fake_controller_runtime_disk_recovery", + recorderCacheRetention: retention, + }); + const observed = createObserved(); + + await mkdir(cacheRoot, { recursive: true }); + await writeFile(staleRawPath, Buffer.alloc(128, 3)); + await writeFile(staleOutputPath, Buffer.alloc(256, 4)); + await writeFile( + manifestFile, + JSON.stringify( + { + entries: [ + { + outputPath: staleOutputPath, + policyId: retention.policyId, + rawOutputPath: staleRawPath, + recordingId: "rec_fake_controller_runtime_stale", + }, + ], + version: 1, + }, + null, + 2, + ), + ); + + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "runtime-disk-recovery", + recorderCachePolicies: [retention], + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--recorder-cache-manifest-file", + manifestFile, + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", + ) && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.capture_segments_stitched", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const exhaustedEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_exhausted", + ); + const runtimeRecoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", + ); + const stitchedEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.capture_segments_stitched", + ); + + invariant(job.status === "completed", "runtime disk recovery did not complete the job"); + invariant(state.status === "completed", "runtime disk recovery state did not complete"); + invariant(observed.failures === 0, "runtime disk recovery should not mark the job failed"); + invariant(observed.cacheUploads.length === 1, "runtime disk recovery did not upload once"); + invariant(exhaustedEvent, "runtime disk recovery did not log disk exhaustion"); + invariant( + exhaustedEvent.severity === "warning", + "recoverable runtime disk exhaustion was not warning severity", + ); + invariant( + exhaustedEvent.details?.willRetry === true, + "runtime disk exhaustion did not record retry intent", + ); + invariant(runtimeRecoveredEvent, "runtime disk recovery did not log recovery"); + invariant( + runtimeRecoveredEvent.details?.requiredBytes >= runtimeRecoveredEvent.details?.remainingBytes, + "runtime disk recovery did not preserve required byte evidence", + ); + invariant(stitchedEvent, "runtime disk recovery did not stitch the preserved segment"); + invariant(stitchedEvent.details?.segmentCount === 1, "runtime disk recovery lost segment count"); + invariant(!(await fileExists(staleRawPath)), "runtime disk cleanup left stale raw cache"); + invariant(!(await fileExists(staleOutputPath)), "runtime disk cleanup left stale output cache"); + setActiveScenario(undefined); +} + +export async function runCaptureDeviceRenumberingScenario({ + address, + agentContext, + createJob, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + const stateFile = path.join(smokeRoot, "capture-device-renumbering-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "capture-device-renumbering-health-events.jsonl"); + const captureCommand = await writeDeviceAssertCaptureCommand( + smokeRoot, + "hw:CARD=SMOKE,DEV=0", + ); + const inventory = await writeRenumberedAudioInventoryFixtures(smokeRoot); + const job = createJob({ + captureInterfaceId: "alsa_card_smoke_dev_0", + expectSuccess: true, + jobId: "job_fake_controller_capture_device_renumbering", + name: "capture-device-renumbering", + outputFileName: "rec_fake_controller_capture_device_renumbering.mp3", + recordingId: "rec_fake_controller_capture_device_renumbering", + }); + const observed = createObserved(); + job.command.captureDevice = "hw:2,0"; + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "capture-device-renumbering", + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${inventory.fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--inventory-arecord-command", + inventory.fakeArecordCommand, + "--inventory-proc-asound-pcm-path", + inventory.fakeProcAsoundPcmPath, + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.capture_device_refreshed", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const refreshEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.capture_device_refreshed", + ); + + invariant(job.status === "completed", "capture device renumbering did not complete the job"); + invariant(state.status === "completed", "capture device renumbering state did not complete"); + invariant(observed.cacheUploads.length === 1, "capture device renumbering did not upload once"); + invariant(refreshEvent, "capture device renumbering did not log refresh"); + invariant( + refreshEvent.details?.captureInterfaceId === "alsa_card_smoke_dev_0", + "capture device renumbering used the wrong stable interface id", + ); + invariant( + refreshEvent.details?.previousDevice === "hw:2,0", + "capture device renumbering did not record the stale device", + ); + invariant( + refreshEvent.details?.refreshedDevice === "hw:CARD=SMOKE,DEV=0", + "capture device renumbering did not record the refreshed device", + ); + setActiveScenario(undefined); +} + +export function agentStateStatus(stateFile) { + if (!existsSync(stateFile)) { + return undefined; + } + + try { + return JSON.parse(readFileSync(stateFile, "utf8")).status; + } catch { + return undefined; + } +} + +function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +async function writeDiskPreflightDfCommand(directory) { + const fakeBin = path.join(directory, "fake-disk-preflight-bin"); + const dfScript = path.join(fakeBin, "df"); + const stateFile = path.join(directory, "fake-disk-preflight-df-state.txt"); + + await mkdir(fakeBin, { recursive: true }); + await writeFile( + dfScript, + `#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const stateFile = ${JSON.stringify(stateFile)}; +const previousRuns = existsSync(stateFile) ? Number(readFileSync(stateFile, "utf8")) : 0; +writeFileSync(stateFile, String(previousRuns + 1)); + +console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); +if (previousRuns < 2) { + console.log("rakkr-smoke 1000 900 100 90% /"); +} else { + console.log("rakkr-smoke 1000 100 900 10% /"); +} +`, + ); + + if (process.platform === "win32") { + await writeFile( + path.join(fakeBin, "df.cmd"), + `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, + ); + } else { + await chmod(dfScript, 0o755); + } + + return fakeBin; +} + +async function writeRuntimeDiskRecoveryDfCommand(directory, rawCapturePath, staleCachePath) { + const fakeBin = path.join(directory, "fake-runtime-disk-recovery-bin"); + const dfScript = path.join(fakeBin, "df"); + + await mkdir(fakeBin, { recursive: true }); + await writeFile( + dfScript, + `#!/usr/bin/env node +import { existsSync } from "node:fs"; + +const rawCapturePath = ${JSON.stringify(rawCapturePath)}; +const staleCachePath = ${JSON.stringify(staleCachePath)}; + +// Report the disk as full only while a capture is in flight (the raw capture +// file exists) and the stale cache entry has not yet been swept away. Keying the +// low reading to filesystem state -- rather than a one-shot counter -- keeps the +// system-health monitor and the recording-job disk monitor observing the same +// condition, so the runtime disk-recovery path fires deterministically no matter +// which loop polls df first. The reading flips back to healthy the moment the +// recovery sweep deletes the stale cache files. +console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); +if (existsSync(rawCapturePath) && existsSync(staleCachePath)) { + console.log("rakkr-smoke 10000 9999 1 99% /"); +} else { + console.log("rakkr-smoke 10000 100 9900 1% /"); +} +`, + ); + + if (process.platform === "win32") { + await writeFile( + path.join(fakeBin, "df.cmd"), + `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, + ); + } else { + await chmod(dfScript, 0o755); + } + + return fakeBin; +} diff --git a/scripts/agent-fake-controller-smoke-recovery.mjs b/scripts/agent-fake-controller-smoke-recovery.mjs index c3a8241e..98d3a6fa 100644 --- a/scripts/agent-fake-controller-smoke-recovery.mjs +++ b/scripts/agent-fake-controller-smoke-recovery.mjs @@ -1,19 +1,15 @@ -import { existsSync, readFileSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawnDaemonAgent } from "./agent-fake-controller-smoke-agent.mjs"; import { - writeDeviceAssertCaptureCommand, - writeRenumberedAudioInventoryFixtures, - writeRecoverableRestartCaptureFile, -} from "./agent-fake-controller-smoke-support.mjs"; -import { - fileExists, - invariant, - readJsonLines, - waitFor, -} from "./agent-fake-controller-smoke-utils.mjs"; + agentStateStatus, + runCaptureDeviceRenumberingScenario, + runDiskPreflightRecoveryScenario, + runRuntimeDiskRecoveryScenario, +} from "./agent-fake-controller-smoke-recovery-disk.mjs"; +import { writeRecoverableRestartCaptureFile } from "./agent-fake-controller-smoke-support.mjs"; +import { fileExists, invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; export async function runUploadBoundaryRecoveryScenarios(deps) { await runRestartRecoveryScenario(deps); @@ -479,483 +475,9 @@ async function runUploadedRecoveryScenario({ setActiveScenario(undefined); } -async function runDiskPreflightRecoveryScenario({ - address, - agentContext, - captureCommand, - createJob, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "disk-preflight-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "disk-preflight-recovery-health-events.jsonl"); - const manifestFile = path.join(smokeRoot, "disk-preflight-recovery-manifest.json"); - const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); - const staleRawPath = path.join(cacheRoot, "rec_fake_controller_stale.raw.wav"); - const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_stale.mp3"); - const fakeDfPath = await writeDiskPreflightDfCommand(smokeRoot); - const retention = { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-disk-preflight-recovery-smoke", - }; - const job = createJob({ - captureChannels: 4, - durationSeconds: 2, - expectSuccess: true, - jobId: "job_fake_controller_disk_preflight_recovery", - name: "disk-preflight-recovery", - outputFileName: "rec_fake_controller_disk_preflight_recovery.mp3", - recordingId: "rec_fake_controller_disk_preflight_recovery", - recorderCacheRetention: retention, - }); - const observed = createObserved(); - - await mkdir(cacheRoot, { recursive: true }); - await writeFile(staleRawPath, Buffer.alloc(128, 1)); - await writeFile(staleOutputPath, Buffer.alloc(256, 2)); - await writeFile( - manifestFile, - JSON.stringify( - { - entries: [ - { - outputPath: staleOutputPath, - policyId: retention.policyId, - rawOutputPath: staleRawPath, - recordingId: "rec_fake_controller_stale", - }, - ], - version: 1, - }, - null, - 2, - ), - ); - - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "disk-preflight-recovery", - recorderCachePolicySequence: [[], [retention]], - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--recorder-cache-manifest-file", - manifestFile, - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.disk_space_recovered", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const cleanupEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_cleanup_attempted", - ); - const recoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_recovered", - ); - - invariant(job.status === "completed", "disk preflight recovery did not complete the job"); - invariant(state.status === "completed", "disk preflight recovery state did not complete"); - invariant(observed.cacheUploads.length === 1, "disk preflight recovery did not upload once"); - invariant(cleanupEvent?.details?.deleted === 1, "disk preflight cleanup did not delete cache"); - invariant( - cleanupEvent.details?.items?.[0]?.reason === "min_free_disk", - "disk preflight cleanup did not use min-free policy", - ); - invariant( - recoveredEvent?.details?.freeBytes > recoveredEvent?.details?.initialFreeBytes, - "disk preflight recovery did not record improved free space", - ); - invariant(!(await fileExists(staleRawPath)), "disk preflight cleanup left stale raw cache"); - invariant(!(await fileExists(staleOutputPath)), "disk preflight cleanup left stale output cache"); - setActiveScenario(undefined); -} - -async function runRuntimeDiskRecoveryScenario({ - address, - agentContext, - captureCommand, - createJob, - createObserved, - nodeId: _nodeId, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "runtime-disk-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "runtime-disk-recovery-health-events.jsonl"); - const manifestFile = path.join(smokeRoot, "runtime-disk-recovery-manifest.json"); - const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); - const rawCapturePath = path.join( - cacheRoot, - "rec_fake_controller_runtime_disk_recovery.raw.wav", - ); - const staleRawPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.raw.wav"); - const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.mp3"); - const fakeDfPath = await writeRuntimeDiskRecoveryDfCommand( - smokeRoot, - rawCapturePath, - staleOutputPath, - ); - const retention = { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-runtime-disk-recovery-smoke", - }; - const job = createJob({ - durationSeconds: 2, - expectSuccess: true, - jobId: "job_fake_controller_runtime_disk_recovery", - name: "runtime-disk-recovery", - outputFileName: "rec_fake_controller_runtime_disk_recovery.mp3", - recordingId: "rec_fake_controller_runtime_disk_recovery", - recorderCacheRetention: retention, - }); - const observed = createObserved(); - - await mkdir(cacheRoot, { recursive: true }); - await writeFile(staleRawPath, Buffer.alloc(128, 3)); - await writeFile(staleOutputPath, Buffer.alloc(256, 4)); - await writeFile( - manifestFile, - JSON.stringify( - { - entries: [ - { - outputPath: staleOutputPath, - policyId: retention.policyId, - rawOutputPath: staleRawPath, - recordingId: "rec_fake_controller_runtime_stale", - }, - ], - version: 1, - }, - null, - 2, - ), - ); - - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "runtime-disk-recovery", - recorderCachePolicies: [retention], - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--recorder-cache-manifest-file", - manifestFile, - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", - ) && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.capture_segments_stitched", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const exhaustedEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_exhausted", - ); - const runtimeRecoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", - ); - const stitchedEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.capture_segments_stitched", - ); - - invariant(job.status === "completed", "runtime disk recovery did not complete the job"); - invariant(state.status === "completed", "runtime disk recovery state did not complete"); - invariant(observed.failures === 0, "runtime disk recovery should not mark the job failed"); - invariant(observed.cacheUploads.length === 1, "runtime disk recovery did not upload once"); - invariant(exhaustedEvent, "runtime disk recovery did not log disk exhaustion"); - invariant( - exhaustedEvent.severity === "warning", - "recoverable runtime disk exhaustion was not warning severity", - ); - invariant( - exhaustedEvent.details?.willRetry === true, - "runtime disk exhaustion did not record retry intent", - ); - invariant(runtimeRecoveredEvent, "runtime disk recovery did not log recovery"); - invariant( - runtimeRecoveredEvent.details?.requiredBytes >= runtimeRecoveredEvent.details?.remainingBytes, - "runtime disk recovery did not preserve required byte evidence", - ); - invariant(stitchedEvent, "runtime disk recovery did not stitch the preserved segment"); - invariant(stitchedEvent.details?.segmentCount === 1, "runtime disk recovery lost segment count"); - invariant(!(await fileExists(staleRawPath)), "runtime disk cleanup left stale raw cache"); - invariant(!(await fileExists(staleOutputPath)), "runtime disk cleanup left stale output cache"); - setActiveScenario(undefined); -} - -async function runCaptureDeviceRenumberingScenario({ - address, - agentContext, - createJob, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "capture-device-renumbering-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "capture-device-renumbering-health-events.jsonl"); - const captureCommand = await writeDeviceAssertCaptureCommand( - smokeRoot, - "hw:CARD=SMOKE,DEV=0", - ); - const inventory = await writeRenumberedAudioInventoryFixtures(smokeRoot); - const job = createJob({ - captureInterfaceId: "alsa_card_smoke_dev_0", - expectSuccess: true, - jobId: "job_fake_controller_capture_device_renumbering", - name: "capture-device-renumbering", - outputFileName: "rec_fake_controller_capture_device_renumbering.mp3", - recordingId: "rec_fake_controller_capture_device_renumbering", - }); - const observed = createObserved(); - job.command.captureDevice = "hw:2,0"; - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "capture-device-renumbering", - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${inventory.fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--inventory-arecord-command", - inventory.fakeArecordCommand, - "--inventory-proc-asound-pcm-path", - inventory.fakeProcAsoundPcmPath, - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.capture_device_refreshed", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const refreshEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.capture_device_refreshed", - ); - - invariant(job.status === "completed", "capture device renumbering did not complete the job"); - invariant(state.status === "completed", "capture device renumbering state did not complete"); - invariant(observed.cacheUploads.length === 1, "capture device renumbering did not upload once"); - invariant(refreshEvent, "capture device renumbering did not log refresh"); - invariant( - refreshEvent.details?.captureInterfaceId === "alsa_card_smoke_dev_0", - "capture device renumbering used the wrong stable interface id", - ); - invariant( - refreshEvent.details?.previousDevice === "hw:2,0", - "capture device renumbering did not record the stale device", - ); - invariant( - refreshEvent.details?.refreshedDevice === "hw:CARD=SMOKE,DEV=0", - "capture device renumbering did not record the refreshed device", - ); - setActiveScenario(undefined); -} - -function agentStateStatus(stateFile) { - if (!existsSync(stateFile)) { - return undefined; - } - - try { - return JSON.parse(readFileSync(stateFile, "utf8")).status; - } catch { - return undefined; - } -} - async function writeAgentState(stateFile, state) { await writeFile( stateFile, JSON.stringify({ ...state, updatedAt: "2026-06-25T00:00:00Z" }, null, 2), ); } - -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -async function writeDiskPreflightDfCommand(directory) { - const fakeBin = path.join(directory, "fake-disk-preflight-bin"); - const dfScript = path.join(fakeBin, "df"); - const stateFile = path.join(directory, "fake-disk-preflight-df-state.txt"); - - await mkdir(fakeBin, { recursive: true }); - await writeFile( - dfScript, - `#!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from "node:fs"; - -const stateFile = ${JSON.stringify(stateFile)}; -const previousRuns = existsSync(stateFile) ? Number(readFileSync(stateFile, "utf8")) : 0; -writeFileSync(stateFile, String(previousRuns + 1)); - -console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); -if (previousRuns < 2) { - console.log("rakkr-smoke 1000 900 100 90% /"); -} else { - console.log("rakkr-smoke 1000 100 900 10% /"); -} -`, - ); - - if (process.platform === "win32") { - await writeFile( - path.join(fakeBin, "df.cmd"), - `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, - ); - } else { - await chmod(dfScript, 0o755); - } - - return fakeBin; -} - -async function writeRuntimeDiskRecoveryDfCommand(directory, rawCapturePath, staleCachePath) { - const fakeBin = path.join(directory, "fake-runtime-disk-recovery-bin"); - const dfScript = path.join(fakeBin, "df"); - - await mkdir(fakeBin, { recursive: true }); - await writeFile( - dfScript, - `#!/usr/bin/env node -import { existsSync } from "node:fs"; - -const rawCapturePath = ${JSON.stringify(rawCapturePath)}; -const staleCachePath = ${JSON.stringify(staleCachePath)}; - -// Report the disk as full only while a capture is in flight (the raw capture -// file exists) and the stale cache entry has not yet been swept away. Keying the -// low reading to filesystem state -- rather than a one-shot counter -- keeps the -// system-health monitor and the recording-job disk monitor observing the same -// condition, so the runtime disk-recovery path fires deterministically no matter -// which loop polls df first. The reading flips back to healthy the moment the -// recovery sweep deletes the stale cache files. -console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); -if (existsSync(rawCapturePath) && existsSync(staleCachePath)) { - console.log("rakkr-smoke 10000 9999 1 99% /"); -} else { - console.log("rakkr-smoke 10000 100 9900 1% /"); -} -`, - ); - - if (process.platform === "win32") { - await writeFile( - path.join(fakeBin, "df.cmd"), - `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, - ); - } else { - await chmod(dfScript, 0o755); - } - - return fakeBin; -} diff --git a/scripts/agent-fake-controller-smoke-scenario-helpers.mjs b/scripts/agent-fake-controller-smoke-scenario-helpers.mjs new file mode 100644 index 00000000..403e3fc9 --- /dev/null +++ b/scripts/agent-fake-controller-smoke-scenario-helpers.mjs @@ -0,0 +1,143 @@ +import path from "node:path"; + +import { readJsonLines } from "./agent-fake-controller-smoke-utils.mjs"; + +// Fresh controller-observed counters for one scenario run. +export function createObserved() { + return { + cancelReason: undefined, + cancellations: 0, + cacheUpload: undefined, + cacheUploads: [], + channelMapReads: 0, + channelMapFailures: 0, + claimNextReads: 0, + claims: 0, + configReads: 0, + failureReason: undefined, + failures: 0, + heartbeats: 0, + healthEvents: [], + inventoryReconciles: 0, + jobStatusReads: 0, + jobStatusReadFailures: 0, + claimNextReadFailures: 0, + jobHeartbeatFailures: 0, + maxRunningJobs: 0, + meterFrames: 0, + monitorChunkFailures: 0, + monitorChunks: [], + nextReads: 0, + nodeConfigFailures: 0, + nodeHeartbeatFailures: 0, + nodeHeartbeats: 0, + }; +} + +// Bind the smoke node id so `createJob(scenario)` keeps its single-argument shape +// (the recovery scenarios receive `createJob` in their deps and call it that way). +export function makeCreateJob(nodeId) { + return function createJob(scenario) { + return { + command: { + captureChannels: scenario.captureChannels ?? 1, + captureDevice: "fake-device", + captureFormat: "S16_LE", + captureInterfaceId: scenario.captureInterfaceId ?? null, + captureSampleRate: 48000, + channelMap: scenario.channelMap ?? null, + durationSeconds: scenario.durationSeconds ?? 1, + outputBitrateKbps: 128, + outputCodec: "mp3", + outputFileName: scenario.outputFileName, + outputVbr: true, + recorderCacheRetention: scenario.recorderCacheRetention ?? immediateRetention(), + type: "alsa_capture", + }, + failureReason: undefined, + id: scenario.jobId, + nodeId, + recordingId: scenario.recordingId, + status: "queued", + }; + }; +} + +function immediateRetention() { + return { + deleteAfterUpload: true, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: null, + policyId: "retention-recorder-cache-smoke", + }; +} + +export function deferredSweepRetention() { + return { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: 1, + minFreeDiskPercent: null, + policyId: "retention-recorder-cache-sweep-smoke", + }; +} + +export function minFreeSweepRetention() { + return { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-recorder-cache-min-free-smoke", + }; +} + +export function recorderCachePoliciesForScenario(scenario) { + if (scenario.recorderCachePolicySequence) { + const index = Math.min( + scenario.recorderCachePolicySequenceIndex ?? 0, + scenario.recorderCachePolicySequence.length - 1, + ); + scenario.recorderCachePolicySequenceIndex = index + 1; + + return scenario.recorderCachePolicySequence[index]; + } + + if (scenario.recorderCachePolicies) { + return scenario.recorderCachePolicies; + } + + if (scenario.deferredSweep) { + return [deferredSweepRetention()]; + } + + if (scenario.minFreeSweep) { + return [minFreeSweepRetention()]; + } + + return []; +} + +export function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +// Extra diagnostics folded into a daemon-scenario timeout error: the synced and +// local health-event streams plus the controller-observed counters, so a flake +// shows which job stalled and how far it got — not just the unmet predicate. +export async function daemonScenarioDiagnostics(observed, healthLogFile) { + const localHealth = await readJsonLines(healthLogFile); + + return ( + `\n--- synced health events (${observed.healthEvents.length}) ---\n` + + `${observed.healthEvents.map((event) => event.type).join(",")}\n` + + `--- local health log (${localHealth.length}) ---\n` + + `${localHealth.map((event) => event.type).join(",")}\n` + + `--- observed ---\n` + + `claims=${observed.claims} claimNextReads=${observed.claimNextReads} ` + + `uploads=${observed.cacheUploads.length} configReads=${observed.configReads} ` + + `heartbeats=${observed.heartbeats} jobStatusReads=${observed.jobStatusReads} ` + + `failures=${observed.failures} maxRunningJobs=${observed.maxRunningJobs}` + ); +} diff --git a/scripts/agent-fake-controller-smoke.mjs b/scripts/agent-fake-controller-smoke.mjs index d7f3fc86..de5e9933 100644 --- a/scripts/agent-fake-controller-smoke.mjs +++ b/scripts/agent-fake-controller-smoke.mjs @@ -76,12 +76,22 @@ import { waitForDaemon, } from "./agent-fake-controller-smoke-utils.mjs"; import { createFakeControllerHandler } from "./agent-fake-controller-smoke-controller.mjs"; +import { + createObserved, + daemonScenarioDiagnostics, + deferredSweepRetention, + fakeDfCommandPath, + makeCreateJob, + minFreeSweepRetention, + recorderCachePoliciesForScenario, +} from "./agent-fake-controller-smoke-scenario-helpers.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const smokeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-fake-controller-")); const nodeId = "node_fake_controller_smoke"; const token = "node-token"; const agentContext = { nodeId, repoRoot, smokeRoot, token }; +const createJob = makeCreateJob(nodeId); const scenarios = [ { cacheUploadFails: false, @@ -724,87 +734,6 @@ function assertControllerStopScenario({ healthLogEvents, job, observed, scenario ); } -function createJob(scenario) { - return { - command: { - captureChannels: scenario.captureChannels ?? 1, - captureDevice: "fake-device", - captureFormat: "S16_LE", - captureInterfaceId: scenario.captureInterfaceId ?? null, - captureSampleRate: 48000, - channelMap: scenario.channelMap ?? null, - durationSeconds: scenario.durationSeconds ?? 1, - outputBitrateKbps: 128, - outputCodec: "mp3", - outputFileName: scenario.outputFileName, - outputVbr: true, - recorderCacheRetention: scenario.recorderCacheRetention ?? immediateRetention(), - type: "alsa_capture", - }, - failureReason: undefined, - id: scenario.jobId, - nodeId, - recordingId: scenario.recordingId, - status: "queued", - }; -} - -function immediateRetention() { - return { - deleteAfterUpload: true, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: null, - policyId: "retention-recorder-cache-smoke", - }; -} - -function deferredSweepRetention() { - return { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: 1, - minFreeDiskPercent: null, - policyId: "retention-recorder-cache-sweep-smoke", - }; -} - -function minFreeSweepRetention() { - return { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-recorder-cache-min-free-smoke", - }; -} - -function recorderCachePoliciesForScenario(scenario) { - if (scenario.recorderCachePolicySequence) { - const index = Math.min( - scenario.recorderCachePolicySequenceIndex ?? 0, - scenario.recorderCachePolicySequence.length - 1, - ); - scenario.recorderCachePolicySequenceIndex = index + 1; - - return scenario.recorderCachePolicySequence[index]; - } - - if (scenario.recorderCachePolicies) { - return scenario.recorderCachePolicies; - } - - if (scenario.deferredSweep) { - return [deferredSweepRetention()]; - } - - if (scenario.minFreeSweep) { - return [minFreeSweepRetention()]; - } - - return []; -} - async function assertLocalRecorderCacheDeleted(outputFileName) { const [renderedPath, rawPath] = localRecorderCachePaths(smokeRoot, outputFileName); invariant(!(await fileExists(renderedPath)), `rendered recorder cache remains: ${renderedPath}`); @@ -822,29 +751,6 @@ async function assertAnyLocalRecorderCacheDeleted(outputFileNames) { throw new Error("min-free recorder-cache sweep did not remove any local cache files"); } -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -// Extra diagnostics folded into a daemon-scenario timeout error: the synced and -// local health-event streams plus the controller-observed counters, so a flake -// shows which job stalled and how far it got — not just the unmet predicate. -async function daemonScenarioDiagnostics(observed, healthLogFile) { - const localHealth = await readJsonLines(healthLogFile); - - return ( - `\n--- synced health events (${observed.healthEvents.length}) ---\n` + - `${observed.healthEvents.map((event) => event.type).join(",")}\n` + - `--- local health log (${localHealth.length}) ---\n` + - `${localHealth.map((event) => event.type).join(",")}\n` + - `--- observed ---\n` + - `claims=${observed.claims} claimNextReads=${observed.claimNextReads} ` + - `uploads=${observed.cacheUploads.length} configReads=${observed.configReads} ` + - `heartbeats=${observed.heartbeats} jobStatusReads=${observed.jobStatusReads} ` + - `failures=${observed.failures} maxRunningJobs=${observed.maxRunningJobs}` - ); -} - function healthScenarioDeps(overrides) { return { ...overrides, @@ -891,34 +797,3 @@ function jobScenarioDeps(overrides) { function setActiveScenario(scenario) { activeScenario = scenario; } - -function createObserved() { - return { - cancelReason: undefined, - cancellations: 0, - cacheUpload: undefined, - cacheUploads: [], - channelMapReads: 0, - channelMapFailures: 0, - claimNextReads: 0, - claims: 0, - configReads: 0, - failureReason: undefined, - failures: 0, - heartbeats: 0, - healthEvents: [], - inventoryReconciles: 0, - jobStatusReads: 0, - jobStatusReadFailures: 0, - claimNextReadFailures: 0, - jobHeartbeatFailures: 0, - maxRunningJobs: 0, - meterFrames: 0, - monitorChunkFailures: 0, - monitorChunks: [], - nextReads: 0, - nodeConfigFailures: 0, - nodeHeartbeatFailures: 0, - nodeHeartbeats: 0, - }; -} From 2ef737c0f88261e0864987b468545edca7812fec Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 21:22:24 +0500 Subject: [PATCH 7/9] Point RBAC baseline verifier at the new shared rbac module The permission catalog moved from packages/shared/src/index.ts into rbac.ts when the shared barrel was split, so verify-rbac-audit-baseline.mjs extracted an empty permission set and flagged every API permission literal as unknown. Read the catalog from packages/shared/src/rbac.ts. Co-Authored-By: Claude Opus 4.8 --- scripts/verify-rbac-audit-baseline.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/verify-rbac-audit-baseline.mjs b/scripts/verify-rbac-audit-baseline.mjs index 38ee896e..8341d6d8 100644 --- a/scripts/verify-rbac-audit-baseline.mjs +++ b/scripts/verify-rbac-audit-baseline.mjs @@ -2,7 +2,9 @@ import { access, readdir, readFile } from "node:fs/promises"; import path from "node:path"; const baselineFile = "docs/internal/baselines/RBAC_AUDIT_BASELINE.md"; -const sharedFile = "packages/shared/src/index.ts"; +// The permission catalog moved out of index.ts into its own domain module when +// the shared barrel was split; extract it from there. +const sharedFile = "packages/shared/src/rbac.ts"; const apiSourceDirectory = "apps/api/src"; const requiredPhrases = [ "Default deny", From cb87a7d19d807ab8be0bf5da0d9ffb2d3ed725aa Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 21:33:15 +0500 Subject: [PATCH 8/9] Point baseline verifiers at the refactored module locations The refactor moved invariant-bearing snippets (schema columns, shared schemas, Rust fns, smoke helpers, and split-out test cases) into new files. The baseline verifiers scan hardcoded file lists, so nine of them (scheduler, settings, recording-library, generic-device, first-reliable-recording, health-watchdog, operations, storage-upload, node-lifecycle) lost sight of that evidence. Append the new file locations to each verifier's source list (and, where a verifier asserts the baseline doc references every file / every test file, add the matching path to the baseline .md). Add-only: no snippet, phrase, or existing entry was removed or weakened. All 14 baseline verifiers pass. Co-Authored-By: Claude Opus 4.8 --- .../internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md | 4 ++-- docs/internal/baselines/GENERIC_DEVICE_BASELINE.md | 4 ++-- docs/internal/baselines/OPERATIONS_BASELINE.md | 6 +++--- docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md | 3 +++ docs/internal/baselines/SCHEDULER_BASELINE.md | 2 +- docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md | 6 +++--- docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md | 4 ++-- scripts/verify-first-reliable-recording-baseline.mjs | 5 +++++ scripts/verify-generic-device-baseline.mjs | 2 ++ scripts/verify-health-watchdog-baseline.mjs | 4 ++++ scripts/verify-node-lifecycle-baseline.mjs | 1 + scripts/verify-operations-baseline.mjs | 3 +++ scripts/verify-recording-library-baseline.mjs | 3 +++ scripts/verify-scheduler-baseline.mjs | 2 ++ scripts/verify-settings-templates-baseline.mjs | 4 ++++ scripts/verify-storage-upload-baseline.mjs | 2 ++ 16 files changed, 42 insertions(+), 13 deletions(-) diff --git a/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md b/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md index 9d928b78..1dc4cf36 100644 --- a/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md +++ b/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md @@ -24,7 +24,7 @@ Status: MVP baseline checked. | Check | Evidence | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ad-hoc start metadata, profile, upload policy, capture targeting, and capacity conflict handling | `apps/api/test/recording-start-routes.test.ts`, `apps/api/test/recording-routes.test.ts` | -| Ad-hoc claim, claim-next, heartbeat, cache attach, duplicate attach idempotency, playback, download, stream, file | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | +| Ad-hoc claim, claim-next, heartbeat, cache attach, duplicate attach idempotency, playback, download, stream, file | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-routes-recording-lifecycle.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | | Scheduled due-run metadata, claim, cache attach, playback, download, stream, file | `apps/api/test/schedule-runner.test.ts` | | Cache checksum, duration, waveform preview, file size | `apps/api/test/recording-cache.test.ts` | | Failed/cancelled/lease-expired job health transitions | `apps/api/test/agent-routes.test.ts`, `apps/api/test/recording-job-lease-runner.test.ts` | @@ -32,6 +32,6 @@ Status: MVP baseline checked. | Agent capture/render/cache/channel-map application/retention/stop/controller-terminal/claim-next-status-poll-control-plane-channel-map failure/controller-capacity/concurrency smoke and direct capture output encoding | `scripts/agent-fake-controller-smoke.mjs`, `scripts/agent-fake-controller-smoke-recovery.mjs`, `scripts/agent-fake-controller-smoke-support.mjs`, `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/config.rs`, `crates/recorder-agent/src/main.rs`, `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/recording_job_disk.rs`, `crates/recorder-agent/src/recording_job_recovery.rs`, `crates/recorder-agent/src/recording_job_segments.rs`, `crates/recorder-agent/src/recording_job_upload.rs`, `crates/recorder-agent/src/recorder_cache_retention.rs` | | Playback/download UI readiness and cleanup | `apps/web/src/lib/recording-page-helpers.test.ts` | | Schedule detail playback/download controls | `apps/web/src/lib/schedule-detail-page-helpers.test.ts` | -| Recording jobs workbench, export, stop, and retry controls | `apps/api/test/recording-job-export.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/web/src/pages/jobs.tsx`, `apps/web/src/lib/jobs-page-helpers.test.ts`, `apps/web/src/lib/root-layout-helpers.test.ts` | +| Recording jobs workbench, export, stop, and retry controls | `apps/api/test/recording-job-export.test.ts`, `apps/api/test/recording-job-export-read.test.ts`, `apps/api/test/recording-job-export-lifecycle.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/web/src/pages/jobs.tsx`, `apps/web/src/lib/jobs-page-helpers.test.ts`, `apps/web/src/lib/root-layout-helpers.test.ts` | `mise run recordings:check-first-reliable` validates this baseline, and `mise run check` runs it. diff --git a/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md b/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md index 8aec831f..28e36281 100644 --- a/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md +++ b/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md @@ -53,9 +53,9 @@ jobs, onboard HDA jobs, and PipeWire/JACK command presets. | Controller-managed node audio defaults, node backend filtering, ad-hoc/schedule interface selection, and managed backend selection | `packages/shared/src/index.ts`, `apps/api/src/node-store.ts`, `apps/api/src/node-inventory-routes.ts`, `apps/api/src/node-routes.ts`, `apps/api/src/agent-node-config-route.ts`, `apps/api/src/recording-routes.ts`, `apps/api/src/recording-job-targets.ts`, `apps/api/src/recording-jobs.ts`, `apps/api/src/scheduled-recordings.ts`, `apps/api/test/recording-start-routes.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/api/test/schedule-runner.test.ts`, `apps/api/test/node-inventory-routes.test.ts`, `apps/api/test/node-routes.test.ts`, `apps/api/test/agent-routes.test.ts`, `apps/web/src/pages/nodes.tsx`, `apps/web/src/components/node-inventory-filters.tsx`, `apps/web/src/components/recording-start-panel.tsx`, `apps/web/src/lib/recording-start-helpers.test.ts`, `apps/web/src/components/node-inventory-editors.tsx`, `crates/recorder-agent/src/node_config.rs`, `crates/recorder-agent/src/main.rs`, `crates/recorder-agent/src/main/health_tick.rs` | | Generic capture and meter command arguments | `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/meter_command.rs`, `crates/recorder-agent/src/command_template.rs` | | PipeWire and JACK capture and meter command presets | `crates/recorder-agent/src/config.rs`, `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/meter_command.rs`, `crates/recorder-agent/src/channel_map.rs`, `crates/recorder-agent/src/channel_map/tests.rs`, `crates/recorder-agent/src/node_config.rs` | -| ALSA inventory parsing, configurable probe command and `/proc/asound/pcm` path, stream/hw-params metadata, sysfs serials | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/config.rs` | +| ALSA inventory parsing, configurable probe command and `/proc/asound/pcm` path, stream/hw-params metadata, sysfs serials | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/inventory/alsa.rs`, `crates/recorder-agent/src/config.rs` | | Numeric and named ALSA capture-device inventory matching | `crates/recorder-agent/src/alsa_device.rs` | -| Runtime PipeWire/JACK availability reporting | `crates/recorder-agent/src/inventory.rs` | +| Runtime PipeWire/JACK availability reporting | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/inventory/runtime.rs` | | ALSA and synthetic meter frame generation | `crates/recorder-agent/src/telemetry.rs`, `crates/recorder-agent/src/telemetry/correlation.rs` | | Channel-map render planning for generic capture inputs | `crates/recorder-agent/src/channel_map.rs` | | Linux `snd-aloop` capture, meter, render, clean/fault fixture, and full-agent job smoke tasks | `.mise.toml`, `scripts/alsa-loopback-smoke.sh`, `scripts/agent-loopback-meter-smoke.sh`, `scripts/alsa-loopback-render-smoke.sh`, `scripts/agent-loopback-fixture-smoke.sh`, `scripts/agent-loopback-job-smoke.sh` | diff --git a/docs/internal/baselines/OPERATIONS_BASELINE.md b/docs/internal/baselines/OPERATIONS_BASELINE.md index 0a8f054b..a779a6a4 100644 --- a/docs/internal/baselines/OPERATIONS_BASELINE.md +++ b/docs/internal/baselines/OPERATIONS_BASELINE.md @@ -16,17 +16,17 @@ Status: MVP baseline checked. | Check | Evidence | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Recording organization, facets, bulk metadata, delete | `apps/api/test/recording-routes.test.ts` | +| Recording organization, facets, bulk metadata, delete | `apps/api/test/recording-routes.test.ts`, `apps/api/test/recording-routes-metadata.test.ts` | | Recording manifest CSV export | `apps/api/test/recording-export-routes.test.ts` | | Recording metadata notes | `apps/api/test/recording-metadata-routes.test.ts` | | Recording UI organization helpers | `apps/web/src/lib/recording-page-helpers.test.ts` | -| Settings template reads, writes, RBAC, succeeded audits | `apps/api/test/settings-routes.test.ts` | +| Settings template reads, writes, RBAC, succeeded audits | `apps/api/test/settings-routes.test.ts`, `apps/api/test/settings-routes-mutations.test.ts` | | Settings page RBAC helpers | `apps/web/src/lib/settings-page-helpers.test.ts` | | Audit filtering and CSV export | `apps/api/test/audit-routes.test.ts` | | Audit page RBAC helpers | `apps/web/src/lib/audit-page-helpers.test.ts` | | Upload destination readiness | `apps/api/test/upload-destinations.test.ts` | | Upload policy templates | `apps/api/test/upload-policies.test.ts` | -| Upload runner and queue operations | `apps/api/test/upload-runner.test.ts` and `apps/api/test/recording-upload-queue-routes.test.ts` | +| Upload runner and queue operations | `apps/api/test/upload-runner.test.ts`, `apps/api/test/upload-runner-routes.test.ts`, and `apps/api/test/recording-upload-queue-routes.test.ts` | | Upload runner UI RBAC | `apps/web/src/lib/upload-runner-panel-helpers.test.ts` | `mise run operations:check` validates this baseline, and `mise run check` runs it. diff --git a/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md b/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md index 16506619..1ce7c8b2 100644 --- a/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md +++ b/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md @@ -19,6 +19,9 @@ Status: MVP baseline checked. | Check | Evidence | | ------------------------------------------------------------------------ | ----------------------------------------------------- | | Listing, facets, filters, sorting, pagination, bulk organization, delete | `apps/api/test/recording-routes.test.ts` | +| Bulk metadata organization | `apps/api/test/recording-routes-metadata.test.ts` | +| Bulk and single recording delete | `apps/api/test/recording-routes-delete.test.ts` | +| Ad-hoc recording lifecycle | `apps/api/test/agent-routes-recording-lifecycle.test.ts` | | Route RBAC | `apps/api/test/recording-route-permissions.test.ts` | | Cache checksum and waveform preview | `apps/api/test/recording-cache.test.ts` | | CSV manifest export | `apps/api/test/recording-export-routes.test.ts` | diff --git a/docs/internal/baselines/SCHEDULER_BASELINE.md b/docs/internal/baselines/SCHEDULER_BASELINE.md index 98cbf9c6..9e3a6f03 100644 --- a/docs/internal/baselines/SCHEDULER_BASELINE.md +++ b/docs/internal/baselines/SCHEDULER_BASELINE.md @@ -34,7 +34,7 @@ Status: MVP baseline checked. | Recurrence engine | `apps/api/test/schedule-engine.test.ts` | | Due-run, backend/interface selection, and schedule-owned recordings | `apps/api/test/schedule-runner.test.ts`, `apps/api/test/schedule-routes.test.ts` | | Retention assignment | `packages/db/drizzle/0021_true_midnight.sql`, `apps/api/src/schedule-store.ts`, `apps/web/src/lib/schedule-draft.ts` | -| Route RBAC and operator controls | `apps/api/test/schedule-routes.test.ts` | +| Route RBAC and operator controls | `apps/api/test/schedule-routes.test.ts`, `apps/api/test/schedule-routes-lifecycle.test.ts` | | Calendar occurrences and occurrence moves | `apps/api/test/schedule-engine.test.ts`, `apps/api/test/schedule-occurrence-routes.test.ts` | | Assignment payload and calendar occurrences | `apps/api/test/schedule-occurrence-routes.test.ts` | | UI permissions | `apps/web/src/lib/schedule-page-helpers.test.ts` | diff --git a/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md b/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md index 0d01a7bf..7c79dbce 100644 --- a/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md +++ b/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md @@ -31,14 +31,14 @@ Rakkr centralizes operator-managed recording settings and reusable channel-map t | Area | Files | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Shared contracts | `packages/shared/src/index.ts` | -| Database schema | `packages/db/src/schema.ts` | +| Shared contracts | `packages/shared/src/index.ts`, `packages/shared/src/channel-maps.ts`, `packages/shared/src/recordings.ts` | +| Database schema | `packages/db/src/schema.ts`, `packages/db/src/schema/schedules.ts` | | API routes | `apps/api/src/settings-routes.ts` | | Controller settings | `apps/api/src/settings-controller-routes.ts`, `apps/api/src/controller-settings-store.ts`, `apps/api/test/settings-controller-routes.test.ts` | | Stores | `apps/api/src/settings-store.ts`, `apps/api/src/recording-profile-settings.ts`, `apps/api/src/watchdog-policy-settings.ts` | | Schedule selection | `apps/web/src/components/schedule-form-dialog.tsx` | | Rollout plans | `apps/api/src/channel-map-assignment-plans.ts` | -| API coverage | `apps/api/test/settings-routes.test.ts` | +| API coverage | `apps/api/test/settings-routes.test.ts`, `apps/api/test/settings-routes-mutations.test.ts` | | UI permissions | `apps/web/src/lib/settings-page-helpers.ts`, `apps/web/src/lib/settings-page-helpers.test.ts` | | Upload retention | `apps/api/src/upload-policies.ts`, `apps/api/src/upload-runner.ts`, `apps/api/test/upload-policies.test.ts`, `apps/api/test/upload-runner.test.ts`, `apps/web/src/components/upload-policy-panel.tsx` | | Retention templates | `apps/api/src/retention-policies.ts`, `apps/api/src/retention-policy-routes.ts`, `apps/api/test/retention-policy-routes.test.ts`, `apps/web/src/components/retention-policy-panel.tsx` | diff --git a/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md b/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md index 40b073cb..a8a05bda 100644 --- a/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md +++ b/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md @@ -28,14 +28,14 @@ Status: MVP baseline checked. | Check | Evidence | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Stub, SMB, S3, checksum, retry budget, in-flight lease recovery | `apps/api/test/upload-executor.test.ts` | -| Runner audit, run-now, cache retention, partial fan-out reconciliation | `apps/api/test/upload-runner.test.ts` | +| Runner audit, run-now, cache retention, partial fan-out reconciliation | `apps/api/test/upload-runner.test.ts`, `apps/api/test/upload-runner-routes.test.ts` | | Destination readiness and CRUD | `apps/api/test/upload-destinations.test.ts` | | Policy templates and auto-queue input | `apps/api/test/upload-policies.test.ts` | | Queue retries, duplicate completed-upload attach idempotency, and retrying-item lease expiry | `apps/api/test/upload-queue.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | | Recording queue routes and scoped filters | `apps/api/test/recording-upload-queue-routes.test.ts` | | Settings route RBAC | `apps/api/test/settings-routes.test.ts` | | Upload metrics | `apps/api/test/metrics.test.ts` | -| Agent cache auto-queue lifecycle | `apps/api/test/agent-routes.test.ts` | +| Agent cache auto-queue lifecycle | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-routes-recording-lifecycle.test.ts` | | Upload runner UI RBAC | `apps/web/src/lib/upload-runner-panel-helpers.test.ts` | `mise run storage:check` validates this baseline, and `mise run check` runs it. diff --git a/scripts/verify-first-reliable-recording-baseline.mjs b/scripts/verify-first-reliable-recording-baseline.mjs index 3a71597c..86a6cbf0 100644 --- a/scripts/verify-first-reliable-recording-baseline.mjs +++ b/scripts/verify-first-reliable-recording-baseline.mjs @@ -67,6 +67,11 @@ const sourceFiles = [ "scripts/agent-fake-controller-smoke-recovery.mjs", "scripts/agent-fake-controller-smoke-support.mjs", "scripts/agent-fake-controller-smoke.mjs", + "scripts/agent-fake-controller-smoke-recovery-disk.mjs", + "crates/recorder-agent/src/inventory/alsa.rs", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", + "apps/api/test/recording-job-export-read.test.ts", + "apps/api/test/recording-job-export-lifecycle.test.ts", ]; const baselinePhrases = [ "MVP baseline checked", diff --git a/scripts/verify-generic-device-baseline.mjs b/scripts/verify-generic-device-baseline.mjs index a77db9a5..ae344792 100644 --- a/scripts/verify-generic-device-baseline.mjs +++ b/scripts/verify-generic-device-baseline.mjs @@ -33,6 +33,8 @@ const sourceFiles = [ "crates/recorder-agent/src/meter_command.rs", "crates/recorder-agent/src/node_config.rs", "crates/recorder-agent/src/inventory.rs", + "crates/recorder-agent/src/inventory/alsa.rs", + "crates/recorder-agent/src/inventory/runtime.rs", "crates/recorder-agent/src/telemetry.rs", "crates/recorder-agent/src/telemetry/correlation.rs", "crates/recorder-agent/src/channel_map.rs", diff --git a/scripts/verify-health-watchdog-baseline.mjs b/scripts/verify-health-watchdog-baseline.mjs index 6d700254..904b2838 100644 --- a/scripts/verify-health-watchdog-baseline.mjs +++ b/scripts/verify-health-watchdog-baseline.mjs @@ -36,6 +36,7 @@ const sourceFiles = [ "crates/recorder-agent/src/main/health_tick.rs", "crates/recorder-agent/src/config.rs", "crates/recorder-agent/src/inventory.rs", + "crates/recorder-agent/src/inventory/alsa.rs", "crates/recorder-agent/src/system_health.rs", "crates/recorder-agent/src/health_log.rs", "crates/recorder-agent/src/telemetry.rs", @@ -62,8 +63,11 @@ const sourceFiles = [ "scripts/agent-fake-controller-smoke-agent.mjs", "scripts/agent-fake-controller-smoke-controller.mjs", "scripts/agent-fake-controller-smoke-health.mjs", + "scripts/agent-fake-controller-smoke-health-system.mjs", + "scripts/agent-fake-controller-smoke-health-meters.mjs", "scripts/agent-fake-controller-smoke-jobs.mjs", "scripts/agent-fake-controller-smoke-recovery.mjs", + "scripts/agent-fake-controller-smoke-recovery-disk.mjs", "scripts/agent-fake-controller-smoke-support.mjs", "scripts/agent-fake-controller-smoke.mjs", "apps/api/test/watchdog-runner.test.ts", diff --git a/scripts/verify-node-lifecycle-baseline.mjs b/scripts/verify-node-lifecycle-baseline.mjs index ebef6c7b..2ea3c45e 100644 --- a/scripts/verify-node-lifecycle-baseline.mjs +++ b/scripts/verify-node-lifecycle-baseline.mjs @@ -4,6 +4,7 @@ const baselineFile = "docs/internal/baselines/NODE_LIFECYCLE_BASELINE.md"; const sourceFiles = [ "packages/shared/src/agent-version.ts", "packages/shared/src/index.ts", + "packages/shared/src/nodes.ts", "apps/api/src/node-lifecycle.ts", "apps/api/src/node-lifecycle-routes.ts", "apps/api/src/node-liveness.ts", diff --git a/scripts/verify-operations-baseline.mjs b/scripts/verify-operations-baseline.mjs index 41a3869f..4adb8f4d 100644 --- a/scripts/verify-operations-baseline.mjs +++ b/scripts/verify-operations-baseline.mjs @@ -26,13 +26,16 @@ const sourceFiles = [ "apps/web/src/components/upload-policy-panel.tsx", "apps/web/src/components/upload-runner-panel.tsx", "apps/api/test/recording-routes.test.ts", + "apps/api/test/recording-routes-metadata.test.ts", "apps/api/test/recording-export-routes.test.ts", "apps/api/test/recording-metadata-routes.test.ts", "apps/api/test/settings-routes.test.ts", + "apps/api/test/settings-routes-mutations.test.ts", "apps/api/test/audit-routes.test.ts", "apps/api/test/upload-policies.test.ts", "apps/api/test/upload-destinations.test.ts", "apps/api/test/upload-runner.test.ts", + "apps/api/test/upload-runner-routes.test.ts", "apps/api/test/recording-upload-queue-routes.test.ts", "apps/web/src/lib/recording-page-helpers.test.ts", "apps/web/src/lib/settings-page-helpers.test.ts", diff --git a/scripts/verify-recording-library-baseline.mjs b/scripts/verify-recording-library-baseline.mjs index b3eb20cf..301a1bbc 100644 --- a/scripts/verify-recording-library-baseline.mjs +++ b/scripts/verify-recording-library-baseline.mjs @@ -25,6 +25,9 @@ const sourceFiles = [ "apps/api/test/agent-routes.test.ts", "apps/api/test/schedule-runner.test.ts", "apps/web/src/lib/recording-page-helpers.test.ts", + "apps/api/test/recording-routes-metadata.test.ts", + "apps/api/test/recording-routes-delete.test.ts", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", ]; const baselinePhrases = [ "RBAC-gated", diff --git a/scripts/verify-scheduler-baseline.mjs b/scripts/verify-scheduler-baseline.mjs index 1e771a14..2373adf6 100644 --- a/scripts/verify-scheduler-baseline.mjs +++ b/scripts/verify-scheduler-baseline.mjs @@ -4,6 +4,7 @@ const baselineFile = "docs/internal/baselines/SCHEDULER_BASELINE.md"; const sourceFiles = [ "packages/shared/src/index.ts", "packages/db/src/schema.ts", + "packages/db/src/schema/schedules.ts", "packages/db/drizzle/0021_true_midnight.sql", "apps/api/src/schedule-engine.ts", "apps/api/src/schedule-routes.ts", @@ -15,6 +16,7 @@ const sourceFiles = [ "apps/api/test/schedule-engine.test.ts", "apps/api/test/schedule-runner.test.ts", "apps/api/test/schedule-routes.test.ts", + "apps/api/test/schedule-routes-lifecycle.test.ts", "apps/api/test/schedule-occurrence-routes.test.ts", "apps/web/src/lib/schedule-page-helpers.test.ts", "apps/web/src/lib/schedule-detail-page-helpers.test.ts", diff --git a/scripts/verify-settings-templates-baseline.mjs b/scripts/verify-settings-templates-baseline.mjs index 324a1a18..1c77d3ce 100644 --- a/scripts/verify-settings-templates-baseline.mjs +++ b/scripts/verify-settings-templates-baseline.mjs @@ -64,6 +64,10 @@ const sourceFiles = [ "crates/recorder-agent/src/system_health.rs", "scripts/agent-fake-controller-smoke.mjs", "scripts/agent-fake-controller-smoke-support.mjs", + "packages/shared/src/channel-maps.ts", + "packages/shared/src/recordings.ts", + "packages/db/src/schema/schedules.ts", + "apps/api/test/settings-routes-mutations.test.ts", ]; const baselinePhrases = [ "checked baseline", diff --git a/scripts/verify-storage-upload-baseline.mjs b/scripts/verify-storage-upload-baseline.mjs index a19613ba..de4b9049 100644 --- a/scripts/verify-storage-upload-baseline.mjs +++ b/scripts/verify-storage-upload-baseline.mjs @@ -27,6 +27,7 @@ const sourceFiles = [ "apps/web/src/lib/upload-runner-panel-helpers.ts", "apps/api/test/upload-executor.test.ts", "apps/api/test/upload-runner.test.ts", + "apps/api/test/upload-runner-routes.test.ts", "apps/api/test/upload-destinations.test.ts", "apps/api/test/upload-policies.test.ts", "apps/api/test/upload-queue.test.ts", @@ -35,6 +36,7 @@ const sourceFiles = [ "apps/api/test/settings-routes.test.ts", "apps/api/test/metrics.test.ts", "apps/api/test/agent-routes.test.ts", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", "apps/web/src/lib/upload-runner-panel-helpers.test.ts", ]; const baselinePhrases = [ From 4fb5a6e06a428177437ae4eb8cd55918377e41b7 Mon Sep 17 00:00:00 2001 From: yashau Date: Wed, 8 Jul 2026 22:09:37 +0500 Subject: [PATCH 9/9] Fix flaky runtime-disk-recovery smoke scenario runRuntimeDiskRecoveryScenario asserts the agent's during-capture disk monitor observes an exhausted disk and runs the runtime-recovery path. The fake capture only lived ~750ms, but the recording-job disk monitor polls once per job-poll-second (1s). On a slower CI runner the capture could be spawned and exit inside a single poll interval, so the monitor never saw the full-disk window: the job just completed normally and the asserted disk_space_runtime_recovered / capture_segments_stitched events never fired (the idle cache sweep, which only runs when no job is active, then cleared the seeded stale cache after completion). Real captures far outlast the poll interval, so this was purely a too-short fake capture. Give this scenario a dedicated capture command that lingers 3.5s (parameterized writeFakeCaptureCommandScript with a lingerMs; default 750ms unchanged for all other scenarios), so the disk monitor deterministically polls df while the capture is still in flight. Smoke passes locally; scenario assertions unchanged. Co-Authored-By: Claude Opus 4.8 --- ...ent-fake-controller-smoke-recovery-disk.mjs | 7 ++++++- .../agent-fake-controller-smoke-support.mjs | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/agent-fake-controller-smoke-recovery-disk.mjs b/scripts/agent-fake-controller-smoke-recovery-disk.mjs index d83c3b1e..d3737a9e 100644 --- a/scripts/agent-fake-controller-smoke-recovery-disk.mjs +++ b/scripts/agent-fake-controller-smoke-recovery-disk.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { spawnDaemonAgent } from "./agent-fake-controller-smoke-agent.mjs"; import { writeDeviceAssertCaptureCommand, + writeFakeRuntimeDiskRecoveryCaptureCommand, writeRenumberedAudioInventoryFixtures, } from "./agent-fake-controller-smoke-support.mjs"; import { fileExists, invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; @@ -143,7 +144,6 @@ export async function runDiskPreflightRecoveryScenario({ export async function runRuntimeDiskRecoveryScenario({ address, agentContext, - captureCommand, createJob, createObserved, nodeId: _nodeId, @@ -151,6 +151,11 @@ export async function runRuntimeDiskRecoveryScenario({ setActiveScenario, smokeRoot, }) { + // Use a longer-lived capture so the recording-job disk monitor reliably polls + // df while the capture is still in flight (the default 750 ms capture can be + // spawned and exit inside one 1 s poll interval on a slow runner, so the + // runtime disk-exhaustion window is never observed — see the capture command). + const captureCommand = await writeFakeRuntimeDiskRecoveryCaptureCommand(smokeRoot); const stateFile = path.join(smokeRoot, "runtime-disk-recovery-agent-state.json"); const healthLogFile = path.join(smokeRoot, "runtime-disk-recovery-health-events.jsonl"); const manifestFile = path.join(smokeRoot, "runtime-disk-recovery-manifest.json"); diff --git a/scripts/agent-fake-controller-smoke-support.mjs b/scripts/agent-fake-controller-smoke-support.mjs index d18d0e33..a6b904f3 100644 --- a/scripts/agent-fake-controller-smoke-support.mjs +++ b/scripts/agent-fake-controller-smoke-support.mjs @@ -181,6 +181,15 @@ export async function writeFakeCaptureCommand(directory) { return writeFakeCaptureCommandScript(directory, "fake-capture", false); } +// A capture that lingers well past one job-poll cycle so the recording-job disk +// monitor reliably polls df at least once while the capture is still in flight. +// The default 750 ms capture can, on a slow runner, be spawned and exit inside a +// single 1 s poll interval — the monitor then never observes the runtime +// disk-exhaustion window and the runtime-recovery path silently does not fire. +export async function writeFakeRuntimeDiskRecoveryCaptureCommand(directory) { + return writeFakeCaptureCommandScript(directory, "fake-runtime-disk-capture", false, 3500); +} + export async function writeDeviceAssertCaptureCommand(directory, expectedDevice) { const captureScript = path.join(directory, "fake-device-assert-capture.mjs"); await writeFile( @@ -608,7 +617,12 @@ process.exit(1); return meterScript; } -async function writeFakeCaptureCommandScript(directory, commandName, requireTemplateOutputFlag) { +async function writeFakeCaptureCommandScript( + directory, + commandName, + requireTemplateOutputFlag, + lingerMs = 750, +) { const captureScript = path.join(directory, `${commandName}.mjs`); await writeFile( captureScript, @@ -632,7 +646,7 @@ if (${JSON.stringify(requireTemplateOutputFlag)} && outputFlagIndex < 0) { mkdirSync(path.dirname(outputPath), { recursive: true }); writeFileSync(outputPath, wavFile(channels, [0, 12000, -12000, 6000, -6000, 3000])); -await new Promise((resolve) => setTimeout(resolve, 750)); +await new Promise((resolve) => setTimeout(resolve, ${lingerMs})); function optionNumber(flags, fallback) { for (const flag of flags) {