Skip to content

Commit 3114739

Browse files
authored
Merge branch 'main' into dependabot/npm_and_yarn/zod-4.3.6
2 parents a3149ec + 16f43e5 commit 3114739

13 files changed

Lines changed: 787 additions & 114 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ jobs:
6464

6565
- name: Upload Playwright report
6666
if: always()
67-
uses: actions/upload-artifact@v4
67+
uses: actions/upload-artifact@v7
6868
with:
6969
name: playwright-report
7070
path: packages/frontend/playwright-report/

.gitignore

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ temp/
4343
packages/frontend/test-results/
4444
packages/frontend/playwright-report/
4545

46-
# Local config
47-
.claude.local.md
46+
**/*.local.*
47+
48+
# AI coding assistants (local state, not shared)
49+
docs/plans/
4850
.plan/
51+
.agent/
52+
.agents/
53+
.augment/
54+
.claude/
55+
.cursor/
56+
.roo/
57+
.full-review/
58+
SECURITY_AUDIT.md
59+
todos/
60+
**/tessl__*
61+
.tessl/tiles/
62+
.tessl/RULES.md

AGENTS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ The backend is a Bun + Hono + TypeScript service:
127127
- **Database (`src/db/`)** — Drizzle client setup and connection config
128128
- **Middleware (`src/middleware/`)** — auth, validation, error handling
129129

130+
### Drizzle ORM raw SQL patterns
131+
132+
- `db.execute(sql`...`)` with postgres-js returns array-like result — access rows as `result[0]`, not `result.rows[0]`
133+
- Drizzle doesn't support `FOR UPDATE SKIP LOCKED` natively — use raw `db.execute(sql`...`)` with a CTE for atomic claim patterns
134+
130135
### RBAC middleware
131136

132137
Two RBAC middleware variants in `src/middleware/rbac.ts`:
@@ -216,6 +221,8 @@ just ci-check # Runs: lint → format-check → type-check → build → test
216221
- **E2E tests**: Playwright for complete user workflows
217222
- **Contract tests**: Validate Agent API responses against the OpenAPI spec
218223

224+
- **Pure function extraction**: For DB-dependent logic, extract the core algorithm as a pure function and test directly (e.g., DAG validation, threshold decisions) — avoids complex DB mocking
225+
219226
Test the hot paths first: hash submission ingestion, work unit distribution, agent heartbeat processing.
220227

221228
## Design and documentation sources
@@ -267,9 +274,12 @@ Without this, auth middleware 401 responses get swallowed into 500s.
267274

268275
## Testing infrastructure
269276

270-
- Backend contract tests validate auth (401) and validation (400) without a running DB
277+
- Backend contract tests validate auth (401), validation (400), and camelCase response shapes (200) without a running DB
271278
- Drizzle mock chains must match production code — e.g. `insert().values()` returning `{ onConflictDoNothing: mock() }`
272279
- BullMQ worker test mocks: if worker does `db.select()`, mock must return chainable `{ from: mock(() => chain), where: mock(() => Promise.resolve([])) }`
280+
- **bun:test `mock.module()`**: Mock dependencies before `await import()` of module under test — used for service tests that need DB/queue mocks
281+
- **Route-level contract tests**: When mocking for `import { app }`, mock ALL transitive service dependencies (e.g., `tasks.js``events.js` + `campaigns.js`). Mock `db.execute` with snake_case rows to validate the camelCase mapping, not the service function directly.
282+
- **Separate test files for conflicting mocks**: If a module is already imported at top level in one test file (e.g., `resolveGenerationStrategy` in `campaigns.test.ts`), tests needing full module mocks for the same source must go in a separate test file to avoid import-order conflicts.
273283
- Frontend tests use `happy-dom` with manual global injection (not `@happy-dom/global-registrator`)
274284
- Always call `afterEach(cleanup)` in Testing Library tests — DOM persists in happy-dom
275285
- Test fixtures: `packages/backend/tests/fixtures.ts` — factory functions + token helpers

packages/backend/src/services/campaigns.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,34 @@ import { and, desc, eq, sql } from 'drizzle-orm';
33
import { db } from '../db/index.js';
44
import { emitCampaignStatus } from './events.js';
55

6-
// Threshold: if estimated task count is below this, generate inline
7-
const INLINE_GENERATION_THRESHOLD = 50;
6+
// Threshold: inline generation when estimated tasks < 100, async enqueue when >= 100
7+
export const INLINE_GENERATION_THRESHOLD = 100;
8+
const CHUNK_SIZE = 10_000_000;
9+
10+
// Dynamic import getters — break circular dependency (campaigns ↔ tasks) while
11+
// remaining testable. bun:test's mock.module cannot override already-cached
12+
// dynamic imports across test files, so tests swap these getters instead.
13+
export const _deps = {
14+
getTasksModule: () => import('./tasks.js'),
15+
getQueueContext: () => import('../queue/context.js'),
16+
getQueueConfig: () => import('../config/queue.js'),
17+
getQueueTypes: () => import('../queue/types.js'),
18+
};
19+
20+
/**
21+
* Estimates total task count and returns the generation strategy.
22+
* Exported for direct unit testing of the threshold boundary.
23+
*/
24+
export function resolveGenerationStrategy(
25+
attackList: ReadonlyArray<{ keyspace: string | null }>
26+
): 'inline' | 'async' {
27+
let estimatedTasks = 0;
28+
for (const atk of attackList) {
29+
const keyspace = Number.parseInt(atk.keyspace ?? '0', 10);
30+
estimatedTasks += keyspace <= 0 ? 1 : Math.ceil(keyspace / CHUNK_SIZE);
31+
}
32+
return estimatedTasks < INLINE_GENERATION_THRESHOLD ? 'inline' : 'async';
33+
}
834

935
// ─── Campaign CRUD ──────────────────────────────────────────────────
1036

@@ -127,7 +153,7 @@ export async function transitionCampaign(id: number, targetStatus: CampaignStatu
127153

128154
// When starting/resuming a campaign, verify queue availability before transitioning
129155
if (targetStatus === 'running') {
130-
const { getQueueManager } = await import('../queue/context.js');
156+
const { getQueueManager } = await _deps.getQueueContext();
131157
const qm = getQueueManager();
132158
if (!qm) {
133159
return {
@@ -184,23 +210,17 @@ export async function transitionCampaign(id: number, targetStatus: CampaignStatu
184210
if (targetStatus === 'running') {
185211
const campaignAttacks = await listAttacks(id);
186212
if (campaignAttacks.length > 0) {
187-
// Estimate task count: sum keyspace / chunk-size across attacks
188-
const CHUNK_SIZE = 10_000_000;
189-
let estimatedTasks = 0;
190-
for (const atk of campaignAttacks) {
191-
const keyspace = Number.parseInt(atk.keyspace ?? '0', 10);
192-
estimatedTasks += keyspace <= 0 ? 1 : Math.ceil(keyspace / CHUNK_SIZE);
193-
}
213+
const strategy = resolveGenerationStrategy(campaignAttacks);
194214

195-
if (estimatedTasks <= INLINE_GENERATION_THRESHOLD) {
215+
if (strategy === 'inline') {
196216
// Generate inline in parallel — small enough to not block the request meaningfully
197-
const { generateTasksForAttack } = await import('./tasks.js');
217+
const { generateTasksForAttack } = await _deps.getTasksModule();
198218
await Promise.all(campaignAttacks.map((atk) => generateTasksForAttack(atk.id)));
199219
} else {
200220
// Enqueue to the dedicated task-generation job queue
201-
const { getQueueManager } = await import('../queue/context.js');
202-
const { QUEUE_NAMES } = await import('../config/queue.js');
203-
const { JOB_PRIORITY } = await import('../queue/types.js');
221+
const { getQueueManager } = await _deps.getQueueContext();
222+
const { QUEUE_NAMES } = await _deps.getQueueConfig();
223+
const { JOB_PRIORITY } = await _deps.getQueueTypes();
204224
const qm = getQueueManager();
205225
if (qm) {
206226
const priorityMap: Record<number, number> = {

packages/backend/src/services/tasks.ts

Lines changed: 88 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { agents, attacks, campaigns, hashItems, tasks } from '@hashhive/shared';
2-
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
2+
import { and, desc, eq, type SQL, sql } from 'drizzle-orm';
33
import { db } from '../db/index.js';
44
import { updateCampaignProgress } from './campaigns.js';
55
import { emitCrackResult, emitTaskUpdate } from './events.js';
@@ -88,45 +88,47 @@ export async function generateTasksForAttack(
8888
// ─── Task Assignment ────────────────────────────────────────────────
8989

9090
/**
91-
* Checks whether an agent's capabilities satisfy a task's required capabilities.
92-
* Uses JSON containment logic: agent caps must include all required keys/values.
91+
* Builds a SQL predicate that checks whether the agent's capabilities satisfy
92+
* a task's required_capabilities column at the database level.
93+
*
94+
* Covers:
95+
* - GPU requirement: task requires `gpu: true` → agent capabilities must contain `{"gpu": true}`
96+
* - Hash mode compatibility: task's `hashcatMode` value must be in agent's `hashModes` array
9397
*/
94-
function agentMatchesCapabilities(
95-
agentCaps: Record<string, unknown>,
96-
requiredCaps: Record<string, unknown>
97-
): boolean {
98-
for (const [key, requiredValue] of Object.entries(requiredCaps)) {
99-
const agentValue = agentCaps[key];
100-
101-
// Boolean requirements: agent must have the capability set to true
102-
if (requiredValue === true && agentValue !== true) {
103-
return false;
104-
}
105-
106-
// Non-boolean values: exact match or array containment
107-
if (typeof requiredValue !== 'boolean') {
108-
// hashcatMode required by task → check against agent's hashModes array
109-
if (key === 'hashcatMode' && Array.isArray(agentCaps['hashModes'])) {
110-
if (!agentCaps['hashModes'].includes(requiredValue)) {
111-
return false;
112-
}
113-
} else if (Array.isArray(agentValue)) {
114-
// Generic array containment: agent array must include required value
115-
if (!agentValue.includes(requiredValue)) {
116-
return false;
117-
}
118-
} else if (agentValue !== requiredValue) {
119-
return false;
120-
}
121-
}
122-
}
123-
return true;
98+
function buildCapabilityPredicate(agentCaps: Record<string, unknown>): SQL {
99+
const hasGpu = agentCaps['gpu'] === true;
100+
const rawHashModes = Array.isArray(agentCaps['hashModes']) ? agentCaps['hashModes'] : [];
101+
// Sanitize to finite integers only — NaN, Infinity, non-numeric strings are dropped
102+
const hashModes = rawHashModes
103+
.map((m: unknown) => Number(m))
104+
.filter((n): n is number => Number.isFinite(n) && Number.isInteger(n));
105+
106+
// GPU check: if the task requires GPU, the agent must have it.
107+
// If the agent has GPU, this is always satisfied. If not, exclude GPU-requiring tasks.
108+
const gpuCondition = hasGpu
109+
? sql`TRUE`
110+
: sql`NOT (${tasks.requiredCapabilities}->>'gpu' = 'true')`;
111+
112+
// Hash mode check: the task's required hashcatMode must be in the agent's hashModes array.
113+
// If agent advertises no hashModes (or all were invalid), only tasks without a hashcatMode requirement pass.
114+
const hashModeCondition =
115+
hashModes.length > 0
116+
? sql`(
117+
${tasks.requiredCapabilities}->>'hashcatMode' IS NULL
118+
OR (${tasks.requiredCapabilities}->>'hashcatMode')::int = ANY(${hashModes}::int[])
119+
)`
120+
: sql`(${tasks.requiredCapabilities}->>'hashcatMode' IS NULL)`;
121+
122+
return sql`(${gpuCondition} AND ${hashModeCondition})`;
124123
}
125124

126125
/**
127126
* Assigns the next available pending task to an agent.
128-
* Filters by project scope and capability predicate.
129-
* Uses SELECT ... FOR UPDATE SKIP LOCKED for safe concurrent assignment.
127+
*
128+
* All eligibility filters (project scope, capability match) are enforced
129+
* in the SQL predicate. Uses `FOR UPDATE SKIP LOCKED` to guarantee only
130+
* one claimant atomically selects and claims a task row, even under
131+
* concurrent access from multiple agents.
130132
*/
131133
export async function assignNextTask(agentId: number) {
132134
// Verify agent exists and is online
@@ -137,47 +139,57 @@ export async function assignNextTask(agentId: number) {
137139

138140
const projectId = agent.projectId;
139141
const agentCaps = (agent.capabilities ?? {}) as Record<string, unknown>;
140-
141-
// Find and claim a pending task atomically using a transaction
142-
const result = await db.transaction(async (tx) => {
143-
// Find pending tasks for campaigns in this agent's project
144-
const pendingTasks = await tx
145-
.select()
146-
.from(tasks)
147-
.where(and(eq(tasks.status, 'pending'), isNull(tasks.agentId)))
148-
.innerJoin(
149-
campaigns,
150-
and(eq(tasks.campaignId, campaigns.id), eq(campaigns.projectId, projectId))
151-
)
152-
.orderBy(campaigns.priority, tasks.id)
153-
.limit(10);
154-
155-
// Find first task whose required capabilities the agent satisfies
156-
const matchingTask = pendingTasks.find((row) => {
157-
const requiredCaps = (row.tasks.requiredCapabilities ?? {}) as Record<string, unknown>;
158-
return agentMatchesCapabilities(agentCaps, requiredCaps);
159-
});
160-
161-
if (!matchingTask) {
162-
return null;
163-
}
164-
165-
// Claim the task
166-
const [assigned] = await tx
167-
.update(tasks)
168-
.set({
169-
agentId,
170-
status: 'assigned',
171-
assignedAt: new Date(),
172-
updatedAt: new Date(),
173-
})
174-
.where(and(eq(tasks.id, matchingTask.tasks.id), eq(tasks.status, 'pending')))
175-
.returning();
176-
177-
return assigned ?? null;
178-
});
179-
180-
return result;
142+
const capabilityPredicate = buildCapabilityPredicate(agentCaps);
143+
144+
// Atomic candidate selection + claim via raw SQL with FOR UPDATE SKIP LOCKED
145+
const result = await db.execute(sql`
146+
WITH candidate AS (
147+
SELECT ${tasks.id} AS task_id
148+
FROM ${tasks}
149+
INNER JOIN ${campaigns} ON ${tasks.campaignId} = ${campaigns.id}
150+
WHERE ${tasks.status} = 'pending'
151+
AND ${tasks.agentId} IS NULL
152+
AND ${campaigns.projectId} = ${projectId}
153+
AND ${capabilityPredicate}
154+
ORDER BY ${campaigns.priority}, ${tasks.id}
155+
LIMIT 1
156+
FOR UPDATE OF ${tasks} SKIP LOCKED
157+
)
158+
UPDATE ${tasks}
159+
SET
160+
agent_id = ${agentId},
161+
status = 'assigned',
162+
assigned_at = NOW(),
163+
updated_at = NOW()
164+
FROM candidate
165+
WHERE ${tasks.id} = candidate.task_id
166+
RETURNING ${tasks.id}, ${tasks.attackId}, ${tasks.campaignId}, ${tasks.agentId},
167+
${tasks.status}, ${tasks.workRange}, ${tasks.progress}, ${tasks.resultStats},
168+
${tasks.requiredCapabilities}, ${tasks.assignedAt}, ${tasks.startedAt},
169+
${tasks.completedAt}, ${tasks.failureReason}, ${tasks.createdAt}, ${tasks.updatedAt}
170+
`);
171+
172+
const row = result[0] as Record<string, unknown> | undefined;
173+
if (!row) return null;
174+
175+
// Map snake_case DB columns back to camelCase to preserve the public API contract
176+
return {
177+
id: row['id'],
178+
attackId: row['attack_id'],
179+
campaignId: row['campaign_id'],
180+
agentId: row['agent_id'],
181+
status: row['status'],
182+
workRange: row['work_range'],
183+
progress: row['progress'],
184+
resultStats: row['result_stats'],
185+
requiredCapabilities: row['required_capabilities'],
186+
assignedAt: row['assigned_at'],
187+
startedAt: row['started_at'],
188+
completedAt: row['completed_at'],
189+
failureReason: row['failure_reason'],
190+
createdAt: row['created_at'],
191+
updatedAt: row['updated_at'],
192+
};
181193
}
182194

183195
// ─── Task Progress & Results ────────────────────────────────────────

0 commit comments

Comments
 (0)