Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Guild automation API routes now map known precondition failures to actionable
4xx responses instead of opaque 500s (`manifest missing`, `capture required`,
`apply lock active`) (PR #171)
- Guild automation backend apply/reconcile endpoints now execute real Discord
and DB mutations through a shared execution pipeline (capture -> plan ->
protected-op gate -> execute -> persisted final status)
- `/api/guilds/:guildId/automation/apply` and `/reconcile` now return explicit
infrastructure failures when the distributed lock backend is unavailable
(fail-closed contract)
- Guild automation diff now marks permission-tightening updates as protected
operations so `allowProtected` gating applies to destructive updates (PR #171)
- Guild cutover role cleanup now only mutates bots explicitly flagged
Expand Down Expand Up @@ -135,6 +141,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
require `view` and mutating requests require `manage`
- Bot Jest config now maps relative `.js` imports to source modules during test
execution, matching the ESM build import style
- Guild automation reconcile now uses ID-first matching with deterministic
fallback for roles/channels and persists remapped manifest IDs for future
convergent plans
- Shared guild automation lock flow now uses Redis token-based distributed locks
(`SET NX PX` + safe token release) instead of in-memory instance-local locks

## [2.6.11] - 2026-03-12

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ packages/
- Reaction roles, role management
- Centralized guild automation (`/guildconfig`) with manifest capture, drift plans,
reconcile/apply flows, and cutover checklist tracking
- Guild automation API apply/reconcile now execute real mutation runs
(`capture -> plan -> apply`) with persisted run outcomes (`completed`,
`blocked`, `failed`)
- Guild automation reconciliation uses ID-first mapping with deterministic
fallback (role/channel keys) and persists remapped manifest IDs to prevent
repeated create/delete drift loops
- Twitch stream notifications (EventSub WebSocket)
- Last.fm scrobbling integration

Expand Down Expand Up @@ -106,6 +112,8 @@ packages/
- Request logging middleware
- Auth readiness health contract at `GET /api/health/auth-config`
(includes `clientId` and generated `authorizeUrlPreview`, without secrets)
- Guild automation execution locking is Redis-backed and fail-closed when lock
infrastructure is unavailable
- 421 tests (361 backend + 60 frontend), 96% statement coverage

## Quick Start
Expand Down
111 changes: 86 additions & 25 deletions packages/backend/src/routes/guildAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import { managementSchemas as s } from '../schemas/management'
import {
guildAutomationService,
validateGuildAutomationManifest,
type GuildAutomationManifestDocument,
type GuildAutomationPlan,
} from '@lucky/shared/services'

const MANIFEST_NOT_FOUND_MESSAGE = 'No automation manifest found for this guild'
const CAPTURE_REQUIRED_MESSAGE =
'No captured guild state available. Run capture before plan/apply.'
const APPLY_LOCKED_MESSAGE = 'Another automation apply operation is already running'
import {
GuildAutomationApplyLockedError,
GuildAutomationCaptureRequiredError,
GuildAutomationLockUnavailableError,
GuildAutomationManifestNotFoundError,
} from '@lucky/shared/types'
import {
GuildAutomationExecutionError,
guildAutomationExecutionService,
} from '../services/GuildAutomationExecutionService'

function p(val: string | string[]): string {
return typeof val === 'string' ? val : val[0]
Expand All @@ -32,23 +39,31 @@ function mapAutomationServiceError(error: unknown): never {
throw error
}

if (error instanceof Error) {
if (error.message === MANIFEST_NOT_FOUND_MESSAGE) {
throw AppError.notFound('Automation manifest not found')
}
if (error instanceof GuildAutomationManifestNotFoundError) {
throw AppError.notFound('Automation manifest not found')
}

if (error.message === CAPTURE_REQUIRED_MESSAGE) {
throw AppError.badRequest(
'No captured guild state available. Run capture before plan/apply.',
)
}
if (error instanceof GuildAutomationCaptureRequiredError) {
throw AppError.badRequest(
'No captured guild state available. Run capture before plan/apply.',
)
}

if (error.message === APPLY_LOCKED_MESSAGE) {
throw AppError.badRequest(
'Another automation apply operation is already running',
)
}
if (error instanceof GuildAutomationApplyLockedError) {
throw AppError.badRequest(
'Another automation apply operation is already running',
)
}

if (error instanceof GuildAutomationLockUnavailableError) {
throw new AppError(503, 'Guild automation lock backend is unavailable')
}

if (error instanceof GuildAutomationExecutionError) {
throw new AppError(error.statusCode, error.message)
}

if (error instanceof Error) {
throw error
}

Expand Down Expand Up @@ -161,9 +176,16 @@ export function setupGuildAutomationRoutes(app: Express): void {
actualState?: unknown
allowProtected?: boolean
}
const actualState = body.actualState
? validateGuildAutomationManifest(body.actualState)
: undefined
let actualState: GuildAutomationManifestDocument | undefined
try {
actualState = body.actualState
? validateGuildAutomationManifest(body.actualState)
: await guildAutomationExecutionService.captureGuildAutomationState(
guildId,
)
} catch (error) {
mapAutomationServiceError(error)
}

let result
try {
Expand All @@ -172,6 +194,22 @@ export function setupGuildAutomationRoutes(app: Express): void {
initiatedBy: userId,
allowProtected: body.allowProtected,
runType: 'apply',
executor: async (params: {
guildId: string
runId: string
plan: GuildAutomationPlan
desired: GuildAutomationManifestDocument
actual: GuildAutomationManifestDocument
allowProtected: boolean
}) => {
return guildAutomationExecutionService.executeApplyPlan({
guildId: params.guildId,
plan: params.plan,
desired: params.desired,
actual: params.actual,
allowProtected: params.allowProtected,
})
},
})
} catch (error) {
mapAutomationServiceError(error)
Expand All @@ -194,9 +232,16 @@ export function setupGuildAutomationRoutes(app: Express): void {
actualState?: unknown
allowProtected?: boolean
}
const actualState = body.actualState
? validateGuildAutomationManifest(body.actualState)
: undefined
let actualState: GuildAutomationManifestDocument | undefined
try {
actualState = body.actualState
? validateGuildAutomationManifest(body.actualState)
: await guildAutomationExecutionService.captureGuildAutomationState(
guildId,
)
} catch (error) {
mapAutomationServiceError(error)
}

let result
try {
Expand All @@ -205,6 +250,22 @@ export function setupGuildAutomationRoutes(app: Express): void {
initiatedBy: userId,
allowProtected: body.allowProtected,
runType: 'reconcile',
executor: async (params: {
guildId: string
runId: string
plan: GuildAutomationPlan
desired: GuildAutomationManifestDocument
actual: GuildAutomationManifestDocument
allowProtected: boolean
}) => {
return guildAutomationExecutionService.executeApplyPlan({
guildId: params.guildId,
plan: params.plan,
desired: params.desired,
actual: params.actual,
allowProtected: params.allowProtected,
})
},
})
} catch (error) {
mapAutomationServiceError(error)
Expand Down
Loading
Loading