thevoidforge 21.0.10 → 21.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/scripts/voidforge.js +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Fixed-Timestep Game Loop
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Fixed timestep for physics/logic (deterministic, reproducible)
|
|
6
|
+
* - Variable rendering (render as fast as possible, interpolate between states)
|
|
7
|
+
* - Frame budget tracking (detect when logic takes too long)
|
|
8
|
+
* - Pause/resume without state corruption
|
|
9
|
+
* - requestAnimationFrame for browser, custom loop for Node/native
|
|
10
|
+
*
|
|
11
|
+
* Agents: Spike-GameDev (architecture), L-Profiler (frame budget), Gimli (performance)
|
|
12
|
+
*
|
|
13
|
+
* Engine adaptations:
|
|
14
|
+
* Phaser: this.time.addEvent() for fixed timestep, scene.update() for variable
|
|
15
|
+
* Godot: _physics_process(delta) for fixed, _process(delta) for variable
|
|
16
|
+
* Unity: FixedUpdate() for fixed, Update() for variable
|
|
17
|
+
* Three.js: This file (manual loop with clock.getDelta())
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// --- Configuration ---
|
|
21
|
+
const FIXED_TIMESTEP = 1 / 60 // 60 Hz physics/logic
|
|
22
|
+
const MAX_FRAME_TIME = 0.25 // Cap to prevent spiral of death
|
|
23
|
+
const FRAME_BUDGET_MS = 16.67 // Target: 60 FPS
|
|
24
|
+
|
|
25
|
+
// --- Game State ---
|
|
26
|
+
interface GameState {
|
|
27
|
+
paused: boolean
|
|
28
|
+
time: number // Total elapsed game time (paused time excluded)
|
|
29
|
+
frameCount: number
|
|
30
|
+
entities: Entity[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Entity {
|
|
34
|
+
x: number
|
|
35
|
+
y: number
|
|
36
|
+
prevX: number // Previous position for interpolation
|
|
37
|
+
prevY: number
|
|
38
|
+
vx: number
|
|
39
|
+
vy: number
|
|
40
|
+
update(dt: number): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- The Loop ---
|
|
44
|
+
|
|
45
|
+
let accumulator = 0
|
|
46
|
+
let lastTime = 0
|
|
47
|
+
let state: GameState = { paused: false, time: 0, frameCount: 0, entities: [] }
|
|
48
|
+
|
|
49
|
+
function gameLoop(currentTime: number): void {
|
|
50
|
+
if (state.paused) {
|
|
51
|
+
lastTime = currentTime // Reset so unpause doesn't cause a time jump
|
|
52
|
+
requestAnimationFrame(gameLoop)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let frameTime = (currentTime - lastTime) / 1000 // Convert ms to seconds
|
|
57
|
+
lastTime = currentTime
|
|
58
|
+
|
|
59
|
+
// Cap frame time to prevent spiral of death (e.g., tab was backgrounded)
|
|
60
|
+
if (frameTime > MAX_FRAME_TIME) frameTime = MAX_FRAME_TIME
|
|
61
|
+
|
|
62
|
+
accumulator += frameTime
|
|
63
|
+
|
|
64
|
+
// --- Fixed timestep: update logic/physics ---
|
|
65
|
+
while (accumulator >= FIXED_TIMESTEP) {
|
|
66
|
+
// Save previous positions for interpolation
|
|
67
|
+
for (const entity of state.entities) {
|
|
68
|
+
entity.prevX = entity.x
|
|
69
|
+
entity.prevY = entity.y
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Update at fixed rate
|
|
73
|
+
for (const entity of state.entities) {
|
|
74
|
+
entity.update(FIXED_TIMESTEP)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
state.time += FIXED_TIMESTEP
|
|
78
|
+
accumulator -= FIXED_TIMESTEP
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Variable rendering: interpolate between states ---
|
|
82
|
+
const alpha = accumulator / FIXED_TIMESTEP // 0.0 to 1.0
|
|
83
|
+
|
|
84
|
+
for (const entity of state.entities) {
|
|
85
|
+
const renderX = entity.prevX + (entity.x - entity.prevX) * alpha
|
|
86
|
+
const renderY = entity.prevY + (entity.y - entity.prevY) * alpha
|
|
87
|
+
// render(entity, renderX, renderY)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Frame budget tracking ---
|
|
91
|
+
const frameEndTime = performance.now()
|
|
92
|
+
const frameDuration = frameEndTime - currentTime
|
|
93
|
+
if (frameDuration > FRAME_BUDGET_MS) {
|
|
94
|
+
console.warn(`Frame ${state.frameCount} exceeded budget: ${frameDuration.toFixed(1)}ms`)
|
|
95
|
+
// L-Profiler flags: investigate which entities/systems took too long
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
state.frameCount++
|
|
99
|
+
requestAnimationFrame(gameLoop)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Pause/Resume ---
|
|
103
|
+
function pause(): void { state.paused = true }
|
|
104
|
+
function resume(): void { state.paused = false }
|
|
105
|
+
|
|
106
|
+
// --- Start ---
|
|
107
|
+
function startGame(): void {
|
|
108
|
+
lastTime = performance.now()
|
|
109
|
+
requestAnimationFrame(gameLoop)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { gameLoop, startGame, pause, resume, FIXED_TIMESTEP }
|
|
113
|
+
export type { GameState, Entity }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Hierarchical Game State Machine
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Every game has states: Menu → Playing → Paused → GameOver (minimum)
|
|
6
|
+
* - Transitions have enter/exit hooks (load assets on enter, clean up on exit)
|
|
7
|
+
* - States can have sub-states (Playing → Cutscene, Playing → Dialog)
|
|
8
|
+
* - History: can return to previous state (Pause → resume to Playing, not Menu)
|
|
9
|
+
* - Serializable: save/load works by serializing the state stack
|
|
10
|
+
*
|
|
11
|
+
* Agents: Spike-GameDev (architecture), Éowyn-GameFeel (transitions), Constantine (state bugs)
|
|
12
|
+
*
|
|
13
|
+
* Engine adaptations:
|
|
14
|
+
* Phaser: this.scene.start('GameOver'), this.scene.pause('Playing')
|
|
15
|
+
* Godot: SceneTree.change_scene(), custom state machine node
|
|
16
|
+
* Unity: SceneManager.LoadScene(), or custom FSM MonoBehaviour
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// --- State Definition ---
|
|
20
|
+
interface GameStateConfig {
|
|
21
|
+
name: string
|
|
22
|
+
enter?: () => void | Promise<void> // Called when entering this state
|
|
23
|
+
exit?: () => void | Promise<void> // Called when leaving this state
|
|
24
|
+
update?: (dt: number) => void // Called every frame while active
|
|
25
|
+
handleInput?: (input: GameInput) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GameInput {
|
|
29
|
+
type: 'keydown' | 'keyup' | 'click' | 'gamepad'
|
|
30
|
+
key?: string
|
|
31
|
+
button?: number
|
|
32
|
+
x?: number
|
|
33
|
+
y?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- State Machine ---
|
|
37
|
+
class StateMachine {
|
|
38
|
+
private states: Map<string, GameStateConfig> = new Map()
|
|
39
|
+
private stack: string[] = [] // State stack for history
|
|
40
|
+
private current: GameStateConfig | null = null
|
|
41
|
+
|
|
42
|
+
register(config: GameStateConfig): void {
|
|
43
|
+
this.states.set(config.name, config)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get currentState(): string | null {
|
|
47
|
+
return this.current?.name ?? null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async transition(stateName: string): Promise<void> {
|
|
51
|
+
const next = this.states.get(stateName)
|
|
52
|
+
if (!next) throw new Error(`Unknown state: ${stateName}`)
|
|
53
|
+
|
|
54
|
+
// Exit current state
|
|
55
|
+
if (this.current?.exit) await this.current.exit()
|
|
56
|
+
|
|
57
|
+
// Push to history
|
|
58
|
+
if (this.current) this.stack.push(this.current.name)
|
|
59
|
+
|
|
60
|
+
// Enter new state
|
|
61
|
+
this.current = next
|
|
62
|
+
if (this.current.enter) await this.current.enter()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async back(): Promise<void> {
|
|
66
|
+
if (this.stack.length === 0) return
|
|
67
|
+
|
|
68
|
+
const previousName = this.stack.pop()!
|
|
69
|
+
const previous = this.states.get(previousName)
|
|
70
|
+
if (!previous) return
|
|
71
|
+
|
|
72
|
+
if (this.current?.exit) await this.current.exit()
|
|
73
|
+
this.current = previous
|
|
74
|
+
if (this.current.enter) await this.current.enter()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
update(dt: number): void {
|
|
78
|
+
if (this.current?.update) this.current.update(dt)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handleInput(input: GameInput): void {
|
|
82
|
+
if (this.current?.handleInput) this.current.handleInput(input)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Serialize for save/load
|
|
86
|
+
serialize(): { current: string | null; stack: string[] } {
|
|
87
|
+
return { current: this.current?.name ?? null, stack: [...this.stack] }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async restore(saved: { current: string | null; stack: string[] }): Promise<void> {
|
|
91
|
+
this.stack = [...saved.stack]
|
|
92
|
+
if (saved.current) {
|
|
93
|
+
this.current = this.states.get(saved.current) ?? null
|
|
94
|
+
if (this.current?.enter) await this.current.enter()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Usage Example ---
|
|
100
|
+
/*
|
|
101
|
+
const sm = new StateMachine()
|
|
102
|
+
|
|
103
|
+
sm.register({
|
|
104
|
+
name: 'menu',
|
|
105
|
+
enter: () => showMainMenu(),
|
|
106
|
+
handleInput: (input) => {
|
|
107
|
+
if (input.key === 'Enter') sm.transition('playing')
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
sm.register({
|
|
112
|
+
name: 'playing',
|
|
113
|
+
enter: () => startLevel(),
|
|
114
|
+
exit: () => cleanupLevel(),
|
|
115
|
+
update: (dt) => updateGameplay(dt),
|
|
116
|
+
handleInput: (input) => {
|
|
117
|
+
if (input.key === 'Escape') sm.transition('paused')
|
|
118
|
+
if (playerDead) sm.transition('gameover')
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
sm.register({
|
|
123
|
+
name: 'paused',
|
|
124
|
+
enter: () => showPauseMenu(),
|
|
125
|
+
handleInput: (input) => {
|
|
126
|
+
if (input.key === 'Escape') sm.back() // Resume playing, not go to menu
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
sm.register({
|
|
131
|
+
name: 'gameover',
|
|
132
|
+
enter: () => showGameOverScreen(),
|
|
133
|
+
handleInput: (input) => {
|
|
134
|
+
if (input.key === 'Enter') sm.transition('menu')
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Start the game
|
|
139
|
+
await sm.transition('menu')
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
export { StateMachine }
|
|
143
|
+
export type { GameStateConfig, GameInput }
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Background Job / Queue Worker
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Jobs are idempotent — running the same job twice produces the same result
|
|
6
|
+
* - Idempotency keys prevent duplicate processing
|
|
7
|
+
* - Retry with exponential backoff for transient failures
|
|
8
|
+
* - Dead letter queue for permanently failed jobs
|
|
9
|
+
* - Minimal payloads — pass IDs, fetch fresh data in the handler
|
|
10
|
+
* - Graceful shutdown — finish current job before stopping
|
|
11
|
+
*
|
|
12
|
+
* Agents: Thor (queue engineer), Barton (error handling), L (monitoring)
|
|
13
|
+
*
|
|
14
|
+
* Framework adaptations:
|
|
15
|
+
* Next.js/Node: BullMQ + Redis, or Inngest for serverless
|
|
16
|
+
* Django: Celery + Redis/RabbitMQ
|
|
17
|
+
* Rails: Sidekiq + Redis, or ActiveJob
|
|
18
|
+
* Express: BullMQ + Redis, or Agenda + MongoDB
|
|
19
|
+
*
|
|
20
|
+
* === Django + Celery Deep Dive ===
|
|
21
|
+
*
|
|
22
|
+
* # app/tasks.py
|
|
23
|
+
* from celery import shared_task
|
|
24
|
+
* from .services import EmailService
|
|
25
|
+
*
|
|
26
|
+
* @shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
|
27
|
+
* def send_welcome_email(self, user_id: int):
|
|
28
|
+
* try:
|
|
29
|
+
* EmailService.send_welcome(user_id)
|
|
30
|
+
* except Exception as exc:
|
|
31
|
+
* self.retry(exc=exc)
|
|
32
|
+
*
|
|
33
|
+
* # Idempotency: use task_id or a dedupe key in the DB to prevent re-processing
|
|
34
|
+
* # Dead letter: configure task_reject_on_worker_lost=True + dlx in broker
|
|
35
|
+
* # Monitoring: Flower (celery monitor) or Sentry integration
|
|
36
|
+
* # Beat scheduler: periodic tasks via django-celery-beat
|
|
37
|
+
*
|
|
38
|
+
* === FastAPI + ARQ Deep Dive ===
|
|
39
|
+
*
|
|
40
|
+
* # app/tasks.py
|
|
41
|
+
* from arq import create_pool
|
|
42
|
+
* from arq.connections import RedisSettings
|
|
43
|
+
*
|
|
44
|
+
* async def send_welcome_email(ctx, user_id: int):
|
|
45
|
+
* await EmailService.send_welcome(user_id)
|
|
46
|
+
*
|
|
47
|
+
* class WorkerSettings:
|
|
48
|
+
* functions = [send_welcome_email]
|
|
49
|
+
* redis_settings = RedisSettings()
|
|
50
|
+
* max_tries = 3
|
|
51
|
+
* retry_delay = 60
|
|
52
|
+
*
|
|
53
|
+
* # ARQ is async-native (matches FastAPI). Alternative: Celery still works with FastAPI.
|
|
54
|
+
* # Same principles: idempotency, retry with backoff, dead letter, monitoring.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// --- Job definition (enqueue side) ---
|
|
58
|
+
|
|
59
|
+
import { Queue } from 'bullmq'
|
|
60
|
+
import { redis } from '@/lib/redis'
|
|
61
|
+
|
|
62
|
+
// One queue per job domain (not one per job type)
|
|
63
|
+
const emailQueue = new Queue('email', { connection: redis })
|
|
64
|
+
|
|
65
|
+
// Enqueue with idempotency key to prevent duplicates
|
|
66
|
+
export async function enqueueWelcomeEmail(userId: string) {
|
|
67
|
+
await emailQueue.add(
|
|
68
|
+
'welcome-email',
|
|
69
|
+
{ userId }, // Minimal payload — just the ID
|
|
70
|
+
{
|
|
71
|
+
jobId: `welcome-email:${userId}`, // Idempotency key
|
|
72
|
+
attempts: 3,
|
|
73
|
+
backoff: {
|
|
74
|
+
type: 'exponential',
|
|
75
|
+
delay: 1000, // 1s, 2s, 4s
|
|
76
|
+
},
|
|
77
|
+
removeOnComplete: { age: 86400 }, // Clean up after 24h
|
|
78
|
+
removeOnFail: { age: 604800 }, // Keep failures for 7 days
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Job handler (worker side) ---
|
|
84
|
+
|
|
85
|
+
import { Worker } from 'bullmq'
|
|
86
|
+
import { db } from '@/lib/db'
|
|
87
|
+
import { emailService } from '@/lib/services/email'
|
|
88
|
+
|
|
89
|
+
const emailWorker = new Worker(
|
|
90
|
+
'email',
|
|
91
|
+
async (job) => {
|
|
92
|
+
const { userId } = job.data
|
|
93
|
+
|
|
94
|
+
// Fetch fresh data — don't trust stale payload
|
|
95
|
+
const user = await db.user.findUnique({ where: { id: userId } })
|
|
96
|
+
|
|
97
|
+
if (!user) {
|
|
98
|
+
// User was deleted between enqueue and processing — skip, don't retry
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
event: 'job.skipped',
|
|
101
|
+
queue: 'email',
|
|
102
|
+
job: job.name,
|
|
103
|
+
jobId: job.id,
|
|
104
|
+
reason: 'user_not_found',
|
|
105
|
+
userId,
|
|
106
|
+
}))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Idempotency check — has this already been sent?
|
|
111
|
+
const alreadySent = await db.emailLog.findFirst({
|
|
112
|
+
where: { userId, type: 'welcome', sentAt: { not: null } },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (alreadySent) {
|
|
116
|
+
console.log(JSON.stringify({
|
|
117
|
+
event: 'job.skipped',
|
|
118
|
+
queue: 'email',
|
|
119
|
+
job: job.name,
|
|
120
|
+
jobId: job.id,
|
|
121
|
+
reason: 'already_sent',
|
|
122
|
+
userId,
|
|
123
|
+
}))
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Do the work
|
|
128
|
+
await emailService.sendWelcome(user)
|
|
129
|
+
|
|
130
|
+
// Record that it was sent (for idempotency on retry)
|
|
131
|
+
await db.emailLog.create({
|
|
132
|
+
data: { userId, type: 'welcome', sentAt: new Date() },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Structured log for monitoring (L — observability)
|
|
136
|
+
console.log(JSON.stringify({
|
|
137
|
+
event: 'job.completed',
|
|
138
|
+
queue: 'email',
|
|
139
|
+
job: job.name,
|
|
140
|
+
jobId: job.id,
|
|
141
|
+
userId,
|
|
142
|
+
duration: Date.now() - job.timestamp,
|
|
143
|
+
}))
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
connection: redis,
|
|
147
|
+
concurrency: 5, // Max parallel jobs
|
|
148
|
+
limiter: {
|
|
149
|
+
max: 10,
|
|
150
|
+
duration: 1000, // Rate limit: 10 jobs/sec
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// --- Error handling ---
|
|
156
|
+
|
|
157
|
+
emailWorker.on('failed', (job, error) => {
|
|
158
|
+
console.error(JSON.stringify({
|
|
159
|
+
event: 'job.failed',
|
|
160
|
+
queue: 'email',
|
|
161
|
+
job: job?.name,
|
|
162
|
+
jobId: job?.id,
|
|
163
|
+
attempt: job?.attemptsMade,
|
|
164
|
+
maxAttempts: job?.opts.attempts,
|
|
165
|
+
error: error.message,
|
|
166
|
+
// If max attempts reached, this goes to the dead letter queue
|
|
167
|
+
deadLettered: job?.attemptsMade === job?.opts.attempts,
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// --- Graceful shutdown ---
|
|
172
|
+
|
|
173
|
+
async function shutdown() {
|
|
174
|
+
console.log('Shutting down email worker...')
|
|
175
|
+
await emailWorker.close() // Finish current job, stop accepting new ones
|
|
176
|
+
process.exit(0)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
process.on('SIGTERM', shutdown)
|
|
180
|
+
process.on('SIGINT', shutdown)
|
|
181
|
+
|
|
182
|
+
// --- Django equivalent (Celery) ---
|
|
183
|
+
/*
|
|
184
|
+
# tasks/email.py
|
|
185
|
+
from celery import shared_task
|
|
186
|
+
from django.core.mail import send_mail
|
|
187
|
+
from users.models import User, EmailLog
|
|
188
|
+
|
|
189
|
+
@shared_task(
|
|
190
|
+
bind=True,
|
|
191
|
+
max_retries=3,
|
|
192
|
+
default_retry_delay=60,
|
|
193
|
+
acks_late=True, # Acknowledge after completion, not before
|
|
194
|
+
)
|
|
195
|
+
def send_welcome_email(self, user_id: str):
|
|
196
|
+
try:
|
|
197
|
+
user = User.objects.get(id=user_id)
|
|
198
|
+
except User.DoesNotExist:
|
|
199
|
+
return # User deleted — skip
|
|
200
|
+
|
|
201
|
+
if EmailLog.objects.filter(user=user, type='welcome').exists():
|
|
202
|
+
return # Already sent — idempotent
|
|
203
|
+
|
|
204
|
+
send_mail(subject='Welcome', message='...', recipient_list=[user.email])
|
|
205
|
+
EmailLog.objects.create(user=user, type='welcome')
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
// --- Rails equivalent (Sidekiq) ---
|
|
209
|
+
/*
|
|
210
|
+
# app/jobs/welcome_email_job.rb
|
|
211
|
+
class WelcomeEmailJob
|
|
212
|
+
include Sidekiq::Job
|
|
213
|
+
sidekiq_options retry: 3, queue: 'email', unique: :until_executed
|
|
214
|
+
|
|
215
|
+
def perform(user_id)
|
|
216
|
+
user = User.find_by(id: user_id)
|
|
217
|
+
return unless user # Deleted — skip
|
|
218
|
+
|
|
219
|
+
return if EmailLog.exists?(user: user, email_type: 'welcome') # Idempotent
|
|
220
|
+
|
|
221
|
+
UserMailer.welcome(user).deliver_now
|
|
222
|
+
EmailLog.create!(user: user, email_type: 'welcome', sent_at: Time.current)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
*/
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Kongo Integration
|
|
3
|
+
* When to use: Integrating an external landing page engine with growth/campaign systems
|
|
4
|
+
* Covers: authenticated client, from-PRD page generation, growth signal, webhook handling
|
|
5
|
+
*
|
|
6
|
+
* This pattern demonstrates first-party API integration for a product the team owns.
|
|
7
|
+
* Unlike adapter patterns (ad-platform-adapter.ts), there's no abstraction layer —
|
|
8
|
+
* the client talks directly to the external API with typed request/response shapes.
|
|
9
|
+
*
|
|
10
|
+
* Architecture: ADR-036
|
|
11
|
+
*
|
|
12
|
+
* Framework adaptations:
|
|
13
|
+
* - Next.js: Webhook route at /api/webhooks/kongo
|
|
14
|
+
* - Express: Router mounted at /webhooks/kongo
|
|
15
|
+
* - Django/DRF: View at /api/webhooks/kongo/ with raw body parsing
|
|
16
|
+
* - FastAPI: POST endpoint with Request.body() for raw access
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ── Section 1: Client Interface ──────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// Authenticated HTTP client with rate limiting.
|
|
22
|
+
// API key stored in financial vault (never .env).
|
|
23
|
+
|
|
24
|
+
interface KongoClientConfig {
|
|
25
|
+
apiKey: string; // ke_live_ prefix, vault-sourced
|
|
26
|
+
baseUrl?: string; // Default: https://kongo.io/api/v1
|
|
27
|
+
timeoutMs?: number; // Default: 30_000
|
|
28
|
+
rateLimitPerMinute?: number; // Default: 60 (client-side safety)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Client methods: get<T>, post<T>, put<T>, patch<T>, delete<T>
|
|
32
|
+
// All return parsed response data (envelope unwrapped).
|
|
33
|
+
// Automatic retry on 429/503 with exponential backoff (3 attempts).
|
|
34
|
+
// Rate limiter: sliding window, throws KongoApiError on exhaustion.
|
|
35
|
+
|
|
36
|
+
// Error type extends the standard ApiError pattern:
|
|
37
|
+
class KongoApiError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
public code: string, // Kongo error code (UNAUTHORIZED, RATE_LIMITED, etc.)
|
|
40
|
+
message: string,
|
|
41
|
+
public status: number, // HTTP status
|
|
42
|
+
public retryable: boolean,
|
|
43
|
+
public retryAfterMs?: number,
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Section 2: From-PRD Page Generation ──────────────────
|
|
50
|
+
//
|
|
51
|
+
// Maps PRD content to Kongo's page creation API.
|
|
52
|
+
// No custom endpoint needed — uses existing POST /engine/pages
|
|
53
|
+
// with the `brief` field for structured input.
|
|
54
|
+
|
|
55
|
+
interface PrdSeedContent {
|
|
56
|
+
projectName: string;
|
|
57
|
+
headline: string;
|
|
58
|
+
subheadline: string;
|
|
59
|
+
valueProps: string[]; // 3-5 bullet points
|
|
60
|
+
ctaText: string;
|
|
61
|
+
ctaUrl: string;
|
|
62
|
+
brandColors: { primary: string; secondary: string; accent: string };
|
|
63
|
+
logoUrl?: string;
|
|
64
|
+
socialProof?: string[];
|
|
65
|
+
campaignId?: string; // VoidForge campaign ID for UTM linking
|
|
66
|
+
platform?: string; // Target ad platform (affects page style)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Seed → Kongo mapping:
|
|
70
|
+
// PrdSeedContent.projectName → CreatePageRequest.companyName
|
|
71
|
+
// PrdSeedContent.* → CreatePageRequest.brief (structured)
|
|
72
|
+
// PrdSeedContent.* → CreatePageRequest.content (flattened text)
|
|
73
|
+
// PrdSeedContent.brandColors → CreatePageRequest.style.colors
|
|
74
|
+
// Always: template='landing-page', hosted=true
|
|
75
|
+
// Metadata: source='voidforge', projectName, campaignId, platform
|
|
76
|
+
|
|
77
|
+
// Polling: awaitPage() polls GET /engine/pages/:id every 3s until READY.
|
|
78
|
+
// Timeout: 120s default. Throws KongoApiError on ERROR status.
|
|
79
|
+
|
|
80
|
+
// ── Section 3: Growth Signal ─────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// Kongo doesn't have a dedicated growth-signal endpoint.
|
|
83
|
+
// Compute it client-side from campaign analytics.
|
|
84
|
+
|
|
85
|
+
interface ComputedGrowthSignal {
|
|
86
|
+
campaignId: string;
|
|
87
|
+
winningVariantId: string | null; // null if no winner yet
|
|
88
|
+
confidence: number; // 0.0-1.0
|
|
89
|
+
conversionRateDelta: number; // Lift over control
|
|
90
|
+
recommendation: 'scale' | 'iterate' | 'kill' | 'wait';
|
|
91
|
+
reasoning: string;
|
|
92
|
+
sampleSize: { control: number; variant: number };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Algorithm: Two-proportion z-test comparing best challenger vs control (first variant by creation order).
|
|
96
|
+
// Minimum thresholds: 200 total views, 100 per variant.
|
|
97
|
+
// Confidence mapping:
|
|
98
|
+
// ≥0.95 + positive delta → 'scale' (winner found)
|
|
99
|
+
// ≥0.80 + positive delta → 'iterate' (promising, need more data)
|
|
100
|
+
// ≥0.95 + negative/zero delta + 500+ views → 'kill'
|
|
101
|
+
// else → 'wait'
|
|
102
|
+
|
|
103
|
+
// ── Section 4: Webhook Handlers ──────────────────────────
|
|
104
|
+
//
|
|
105
|
+
// Kongo sends webhooks for page lifecycle events.
|
|
106
|
+
// Signature: X-Kongo-Signature: t=timestamp,v1=hmac_sha256
|
|
107
|
+
|
|
108
|
+
// Verification steps:
|
|
109
|
+
// 1. Parse header: split by comma → extract t= and v1= parts
|
|
110
|
+
// 2. Check freshness: reject if timestamp > 5 minutes old
|
|
111
|
+
// 3. Compute expected: HMAC-SHA256 of "timestamp.rawBody" with secret
|
|
112
|
+
// 4. Compare: timing-safe comparison (prevents timing attacks)
|
|
113
|
+
|
|
114
|
+
// Events:
|
|
115
|
+
// - page.completed → update page status in heartbeat state
|
|
116
|
+
// - page.failed → log error, flag for retry on next daemon cycle
|
|
117
|
+
|
|
118
|
+
// Router pattern:
|
|
119
|
+
// const router = createWebhookRouter();
|
|
120
|
+
// router.on('page.completed', async (payload) => { ... });
|
|
121
|
+
// router.on('page.failed', async (payload) => { ... });
|
|
122
|
+
// await router.handle(rawBody, signature, secret);
|
|
123
|
+
|
|
124
|
+
// ── Section 5: Campaign-Page Linkage ─────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Ad campaigns point to Kongo pages with UTM parameters.
|
|
127
|
+
|
|
128
|
+
// UTM taxonomy (standardized with Vin):
|
|
129
|
+
// utm_source = voidforge
|
|
130
|
+
// utm_medium = paid | organic | email | social
|
|
131
|
+
// utm_campaign = {voidforge_campaign_id}
|
|
132
|
+
// utm_content = {kongo_variant_id}
|
|
133
|
+
// utm_term = {keyword} (paid search only)
|
|
134
|
+
|
|
135
|
+
// Kongo campaigns use rotation strategies:
|
|
136
|
+
// 'weighted' — manual traffic weights per variant
|
|
137
|
+
// 'equal' — even split across all variants
|
|
138
|
+
// 'bandit' — multi-armed bandit (auto-optimize)
|
|
139
|
+
|
|
140
|
+
// AI variant generation:
|
|
141
|
+
// POST /engine/campaigns/:id/variants/generate
|
|
142
|
+
// count: 1-20, vary: ['headline', 'tagline', 'cta_text'], context: string
|
|
143
|
+
// ~$0.01, ~3s for 5 variants (Claude Sonnet)
|
|
144
|
+
|
|
145
|
+
// ── Section 6: Daemon Jobs ───────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// Three Kongo-specific heartbeat jobs:
|
|
148
|
+
|
|
149
|
+
// kongo-signal (hourly):
|
|
150
|
+
// Poll getGrowthSignal() for all published campaigns.
|
|
151
|
+
// Log signals to heartbeat.json. Forward 'scale'/'kill' for daemon action.
|
|
152
|
+
// Fault-tolerant: continues polling other campaigns if one fails.
|
|
153
|
+
|
|
154
|
+
// kongo-seed (event-driven):
|
|
155
|
+
// Triggered when Wayne's A/B evaluation declares a page variant winner.
|
|
156
|
+
// Extracts winning variant's slotValues.
|
|
157
|
+
// Available as seed for next createPageFromPrd() cycle.
|
|
158
|
+
|
|
159
|
+
// kongo-webhook (event-driven):
|
|
160
|
+
// Receives POST from Kongo on daemon's callback port.
|
|
161
|
+
// Verifies HMAC signature. Routes to typed handlers.
|
|
162
|
+
// Conditional: only registered when Kongo API key exists in vault.
|
|
163
|
+
|
|
164
|
+
export {};
|