diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 1f76d79..2c9c071 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -16,7 +16,7 @@ export async function authenticate(req: Request, _res: Response, next: NextFunct } const user = await userRepo.findById(claims.sub); if (!user || !user.isActive) throw unauthorized(); - req.actor = { id: user._id.toString(), role: user.role }; + req.actor = { id: user._id.toString(), role: user.role, kind: 'human' }; req.sessionId = claims.sid; next(); } catch (err) { diff --git a/backend/src/models/agent-config-audit.model.ts b/backend/src/models/agent-config-audit.model.ts new file mode 100644 index 0000000..603b557 --- /dev/null +++ b/backend/src/models/agent-config-audit.model.ts @@ -0,0 +1,28 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; + +const { Schema, model, models } = mongoose; + +export interface AgentConfigAuditDoc { + _id: ObjectId; + agentName: string; + actorId: string; + action: string; + before: unknown; + after: unknown; + createdAt: Date; + updatedAt: Date; +} + +const agentConfigAuditSchema = new Schema( + { + agentName: { type: String, required: true }, + actorId: { type: String, required: true }, + action: { type: String, required: true }, + before: { type: Schema.Types.Mixed }, + after: { type: Schema.Types.Mixed }, + }, + { timestamps: true }, +); + +export const AgentConfigAudit = (models.AgentConfigAudit ?? model('AgentConfigAudit', agentConfigAuditSchema)) as Model; \ No newline at end of file diff --git a/backend/src/models/agent-config.model.ts b/backend/src/models/agent-config.model.ts new file mode 100644 index 0000000..989e269 --- /dev/null +++ b/backend/src/models/agent-config.model.ts @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; +import type { AgentConfig as AgentConfigDTO, TriageMode, WorkOrderPriority } from '@workorders/shared'; + +const { Schema, model, models } = mongoose; + +export interface AgentConfigDoc { + _id: ObjectId; + name: string; + enabled: boolean; + mode: TriageMode; + allowedFields: string[]; + dailyActionCap: number; + flagThreshold: WorkOrderPriority; + workingHours: string; + updatedBy: string | null; + updatedAt: Date; +} + +const agentConfigSchema = new Schema( + { + name: { type: String, required: true, unique: true, default: 'triage' }, + enabled: { type: Boolean, default: true }, + mode: { type: String, enum: ['suggest', 'auto-apply'], default: 'suggest' }, + allowedFields: { type: [String], default: ['priority'] }, + dailyActionCap: { type: Number, default: 50 }, + flagThreshold: { type: String, enum: ['low', 'medium', 'high'], default: 'high' }, + workingHours: { type: String, default: '*' }, + updatedBy: { type: String, default: null }, + }, + { timestamps: true }, +); + +export const AgentConfig = (models.AgentConfig ?? model('AgentConfig', agentConfigSchema)) as Model; + +export function toAgentConfigPublic(config: AgentConfigDoc): AgentConfigDTO { + return { + name: config.name, + enabled: config.enabled, + mode: config.mode, + allowedFields: config.allowedFields, + dailyActionCap: config.dailyActionCap, + flagThreshold: config.flagThreshold, + workingHours: config.workingHours, + updatedBy: config.updatedBy, + updatedAt: config.updatedAt.toISOString(), + }; +} \ No newline at end of file diff --git a/backend/src/models/agent-message.model.ts b/backend/src/models/agent-message.model.ts new file mode 100644 index 0000000..16f034b --- /dev/null +++ b/backend/src/models/agent-message.model.ts @@ -0,0 +1,34 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; + +const { Schema, model, models } = mongoose; + +export type AgentMessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface AgentMessageDoc { + _id: ObjectId; + runId: ObjectId; + role: AgentMessageRole; + content: string; + toolCallId?: string; + name?: string; + expiresAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +const agentMessageSchema = new Schema( + { + runId: { type: Schema.ObjectId, ref: 'AgentRun', required: true, index: true }, + role: { type: String, enum: ['system', 'user', 'assistant', 'tool'], required: true }, + content: { type: String, required: true }, + toolCallId: { type: String }, + name: { type: String }, + expiresAt: { type: Date, default: null }, + }, + { timestamps: true }, +); + +agentMessageSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const AgentMessage = (models.AgentMessage ?? model('AgentMessage', agentMessageSchema)) as Model; \ No newline at end of file diff --git a/backend/src/models/agent-run.model.ts b/backend/src/models/agent-run.model.ts new file mode 100644 index 0000000..b317220 --- /dev/null +++ b/backend/src/models/agent-run.model.ts @@ -0,0 +1,65 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; +import type { AgentRun as AgentRunDTO, AgentRunMode, AgentRunStatus, ErrorCode } from '@workorders/shared'; + +const { Schema, model, models } = mongoose; + +export interface AgentRunDoc { + _id: ObjectId; + sessionId: ObjectId | null; + userId: ObjectId | null; + mode: AgentRunMode; + agentName?: string; + status: AgentRunStatus; + model: string; + inputTokens: number; + outputTokens: number; + errorCode?: string; + finishedAt: Date | null; + leaseUntil: Date | null; + createdAt: Date; + updatedAt: Date; +} + +const agentRunSchema = new Schema( + { + sessionId: { type: Schema.ObjectId, ref: 'CopilotSession', default: null }, + userId: { type: Schema.ObjectId, ref: 'User', default: null }, + mode: { type: String, enum: ['copilot', 'autonomous'], required: true }, + agentName: { type: String }, + status: { + type: String, + enum: ['running', 'complete', 'error', 'budget_exceeded', 'expired', 'aborted'], + default: 'running', + }, + model: { type: String, required: true }, + inputTokens: { type: Number, default: 0 }, + outputTokens: { type: Number, default: 0 }, + errorCode: { type: String }, + finishedAt: { type: Date, default: null }, + leaseUntil: { type: Date, default: null }, + }, + { timestamps: true }, +); + +agentRunSchema.index({ userId: 1, status: 1 }); +agentRunSchema.index({ sessionId: 1 }); +agentRunSchema.index({ status: 1, leaseUntil: 1 }); + +export const AgentRun = (models.AgentRun ?? model('AgentRun', agentRunSchema)) as Model; + +export function toAgentRunPublic(run: AgentRunDoc): AgentRunDTO { + return { + id: run._id.toString(), + mode: run.mode, + actorId: run.userId ? run.userId.toString() : null, + ...(run.agentName ? { agentName: run.agentName } : {}), + status: run.status, + model: run.model, + inputTokens: run.inputTokens, + outputTokens: run.outputTokens, + startedAt: run.createdAt.toISOString(), + finishedAt: run.finishedAt ? run.finishedAt.toISOString() : null, + ...(run.errorCode ? { errorCode: run.errorCode as ErrorCode } : {}), + }; +} \ No newline at end of file diff --git a/backend/src/models/agent-spend.model.ts b/backend/src/models/agent-spend.model.ts new file mode 100644 index 0000000..3c1e40d --- /dev/null +++ b/backend/src/models/agent-spend.model.ts @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; + +const { Schema, model, models } = mongoose; + +export interface AgentSpendDoc { + _id: ObjectId; + key: string; + spentUsd: number; + createdAt: Date; + updatedAt: Date; +} + +const agentSpendSchema = new Schema( + { + key: { type: String, required: true, unique: true }, + spentUsd: { type: Number, default: 0 }, + }, + { timestamps: true }, +); + +export const AgentSpend = (models.AgentSpend ?? model('AgentSpend', agentSpendSchema)) as Model; \ No newline at end of file diff --git a/backend/src/models/agent-tool-call.model.ts b/backend/src/models/agent-tool-call.model.ts new file mode 100644 index 0000000..0204c5b --- /dev/null +++ b/backend/src/models/agent-tool-call.model.ts @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; +import type { AgentApproval, AgentApprovalStatus, AgentToolCall as AgentToolCallDTO, AgentToolOutcome } from '@workorders/shared'; + +const { Schema, model, models } = mongoose; + +export interface AgentToolCallApprovalDoc { + status: AgentApprovalStatus; + summary: string; + expiresAt: Date; + decidedBy?: string; + decidedAt?: Date; +} + +export interface AgentToolCallDoc { + _id: ObjectId; + runId: ObjectId; + tool: string; + args: unknown; + result?: unknown; + outcome: AgentToolOutcome; + latencyMs: number; + stagedVersion?: number; + executedVersion?: number; + preImage?: unknown; + approval?: AgentToolCallApprovalDoc; + expiresAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +const agentToolCallSchema = new Schema( + { + runId: { type: Schema.ObjectId, ref: 'AgentRun', required: true, index: true }, + tool: { type: String, required: true }, + args: { type: Schema.Types.Mixed, required: true }, + result: { type: Schema.Types.Mixed }, + outcome: { + type: String, + enum: ['executed', 'approved', 'rejected', 'expired', 'stale', 'error', 'blocked', 'aborted'], + required: true, + }, + latencyMs: { type: Number, default: 0 }, + stagedVersion: { type: Number }, + executedVersion: { type: Number }, + preImage: { type: Schema.Types.Mixed }, + approval: { + status: { type: String, enum: ['pending', 'approved', 'rejected', 'expired', 'stale'], default: 'pending' }, + summary: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + decidedBy: { type: String }, + decidedAt: { type: Date }, + }, + expiresAt: { type: Date, default: null }, + }, + { timestamps: true }, +); + +agentToolCallSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const AgentToolCall = (models.AgentToolCall ?? model('AgentToolCall', agentToolCallSchema)) as Model; + +export function toAgentToolCallPublic(call: AgentToolCallDoc): AgentToolCallDTO { + return { + id: call._id.toString(), + runId: call.runId.toString(), + tool: call.tool, + args: call.args, + outcome: call.outcome, + latencyMs: call.latencyMs, + createdAt: call.createdAt.toISOString(), + ...(call.result !== undefined ? { result: call.result } : {}), + ...(call.stagedVersion !== undefined ? { stagedVersion: call.stagedVersion } : {}), + ...(call.executedVersion !== undefined ? { executedVersion: call.executedVersion } : {}), + ...(call.preImage !== undefined ? { preImage: call.preImage } : {}), + ...(call.approval + ? { + approval: { + status: call.approval.status, + summary: call.approval.summary, + expiresAt: call.approval.expiresAt.toISOString(), + ...(call.approval.decidedBy !== undefined ? { decidedBy: call.approval.decidedBy } : {}), + ...(call.approval.decidedAt !== undefined ? { decidedAt: call.approval.decidedAt.toISOString() } : {}), + } satisfies AgentApproval, + } + : {}), + }; +} \ No newline at end of file diff --git a/backend/src/models/copilot-session.model.ts b/backend/src/models/copilot-session.model.ts new file mode 100644 index 0000000..bf4ae40 --- /dev/null +++ b/backend/src/models/copilot-session.model.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; +import type { CopilotSession as CopilotSessionDTO, CopilotSessionStatus } from '@workorders/shared'; + +const { Schema, model, models } = mongoose; + +export interface CopilotSessionDoc { + _id: ObjectId; + userId: ObjectId; + status: CopilotSessionStatus; + createdAt: Date; + updatedAt: Date; +} + +const copilotSessionSchema = new Schema( + { + userId: { type: Schema.ObjectId, ref: 'User', required: true }, + status: { type: String, enum: ['active', 'archived', 'expired'], default: 'active' }, + }, + { timestamps: true }, +); + +copilotSessionSchema.index({ userId: 1, status: 1 }); + +export const CopilotSession = (models.CopilotSession ?? model('CopilotSession', copilotSessionSchema)) as Model; + +export function toCopilotSessionPublic(session: CopilotSessionDoc): CopilotSessionDTO { + return { + id: session._id.toString(), + userId: session.userId.toString(), + status: session.status, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + }; +} \ No newline at end of file diff --git a/backend/src/models/outbox-event.model.ts b/backend/src/models/outbox-event.model.ts new file mode 100644 index 0000000..5a337d1 --- /dev/null +++ b/backend/src/models/outbox-event.model.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; + +const { Schema, model, models } = mongoose; + +export type OutboxEventStatus = 'pending' | 'processing' | 'done' | 'failed'; + +export interface OutboxEventDoc { + _id: ObjectId; + type: string; + payloadRef: string; + status: OutboxEventStatus; + claimedAt: Date | null; + leasedUntil: Date | null; + attempts: number; + createdAt: Date; + updatedAt: Date; +} + +const outboxEventSchema = new Schema( + { + type: { type: String, required: true }, + payloadRef: { type: String, required: true }, + status: { type: String, enum: ['pending', 'processing', 'done', 'failed'], default: 'pending' }, + claimedAt: { type: Date, default: null }, + leasedUntil: { type: Date, default: null }, + attempts: { type: Number, default: 0 }, + }, + { timestamps: true }, +); + +outboxEventSchema.index({ type: 1, payloadRef: 1 }, { unique: true }); +outboxEventSchema.index({ status: 1, leasedUntil: 1 }); + +export const OutboxEvent = (models.OutboxEvent ?? model('OutboxEvent', outboxEventSchema)) as Model; \ No newline at end of file diff --git a/backend/src/models/triage-suggestion.model.ts b/backend/src/models/triage-suggestion.model.ts new file mode 100644 index 0000000..530001a --- /dev/null +++ b/backend/src/models/triage-suggestion.model.ts @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import type { Model, ObjectId } from 'mongoose'; +import type { TriageSuggestion as TriageSuggestionDTO, WorkOrderPriority } from '@workorders/shared'; + +const { Schema, model, models } = mongoose; + +export interface TriageSuggestionDoc { + _id: ObjectId; + workOrderId: ObjectId; + runId: ObjectId; + summary: string; + suggestedPriority: WorkOrderPriority; + flagForDispatcher: boolean; + applied: boolean; + createdAt: Date; + updatedAt: Date; +} + +const triageSuggestionSchema = new Schema( + { + workOrderId: { type: Schema.ObjectId, ref: 'WorkOrder', required: true, index: true }, + runId: { type: Schema.ObjectId, ref: 'AgentRun', required: true }, + summary: { type: String, required: true }, + suggestedPriority: { type: String, enum: ['low', 'medium', 'high'], required: true }, + flagForDispatcher: { type: Boolean, default: false }, + applied: { type: Boolean, default: false }, + }, + { timestamps: true }, +); + +export const TriageSuggestion = (models.TriageSuggestion ?? model('TriageSuggestion', triageSuggestionSchema)) as Model; + +export function toTriageSuggestionPublic(suggestion: TriageSuggestionDoc): TriageSuggestionDTO { + return { + id: suggestion._id.toString(), + workOrderId: suggestion.workOrderId.toString(), + runId: suggestion.runId.toString(), + summary: suggestion.summary, + suggestedPriority: suggestion.suggestedPriority, + flagForDispatcher: suggestion.flagForDispatcher, + applied: suggestion.applied, + createdAt: suggestion.createdAt.toISOString(), + }; +} \ No newline at end of file diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts index 2be817e..3a98b07 100644 --- a/backend/src/models/user.model.ts +++ b/backend/src/models/user.model.ts @@ -11,6 +11,7 @@ export interface UserDoc { name: string; role: Role; isActive: boolean; + aiEnabled: boolean; lastLoginAt: Date | null; failedLoginCount: number; failedLoginWindowStartAt: Date | null; @@ -30,6 +31,7 @@ const userSchema = new Schema( name: { type: String, required: true }, role: { type: String, enum: ['admin', 'user', 'viewer'], default: 'user' }, isActive: { type: Boolean, default: true }, + aiEnabled: { type: Boolean, default: true }, lastLoginAt: { type: Date, default: null }, failedLoginCount: { type: Number, default: 0 }, failedLoginWindowStartAt: { type: Date, default: null }, @@ -62,6 +64,7 @@ export function toUserAdmin(user: UserDoc): UserAdmin { return { ...toUserPublic(user), isActive: user.isActive, + aiEnabled: user.aiEnabled, lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : null, }; } \ No newline at end of file diff --git a/backend/src/repositories/agent.repo.ts b/backend/src/repositories/agent.repo.ts new file mode 100644 index 0000000..96dfd15 --- /dev/null +++ b/backend/src/repositories/agent.repo.ts @@ -0,0 +1,287 @@ +import type { + AgentApprovalStatus, + AgentRunMode, + AgentRunStatus, + AgentToolOutcome, + CopilotSessionStatus, + WorkOrderPriority, +} from '@workorders/shared'; +import { CopilotSession, type CopilotSessionDoc } from '../models/copilot-session.model.js'; +import { AgentRun, type AgentRunDoc } from '../models/agent-run.model.js'; +import { AgentMessage, type AgentMessageDoc } from '../models/agent-message.model.js'; +import { AgentToolCall, type AgentToolCallDoc } from '../models/agent-tool-call.model.js'; +import { OutboxEvent, type OutboxEventDoc } from '../models/outbox-event.model.js'; +import { TriageSuggestion, type TriageSuggestionDoc } from '../models/triage-suggestion.model.js'; +import { AgentConfig, type AgentConfigDoc } from '../models/agent-config.model.js'; +import { AgentConfigAudit } from '../models/agent-config-audit.model.js'; +import { AgentSpend } from '../models/agent-spend.model.js'; + +const TTL_MS = 90 * 24 * 60 * 60 * 1000; + +export const agentRepo = { + async createSession(userId: string): Promise { + return CopilotSession.create({ userId }); + }, + + async findSessionById(id: string): Promise { + return CopilotSession.findById(id).lean(); + }, + + async listSessionsForUser(userId: string): Promise { + return CopilotSession.find({ userId }).sort({ createdAt: -1 }).lean(); + }, + + async archiveSession(id: string): Promise { + await CopilotSession.updateOne({ _id: id }, { $set: { status: 'archived' } }); + }, + + async setSessionStatus(id: string, status: CopilotSessionStatus): Promise { + await CopilotSession.updateOne({ _id: id }, { $set: { status } }); + }, + + async createRun(input: { + sessionId: string; + userId: string; + mode: AgentRunMode; + agentName?: string; + model: string; + }): Promise { + return AgentRun.create({ + sessionId: input.sessionId, + userId: input.userId, + mode: input.mode, + agentName: input.agentName, + model: input.model, + }); + }, + + async findRunById(id: string): Promise { + return AgentRun.findById(id).lean(); + }, + + async finishRun( + id: string, + patch: { status: AgentRunStatus; finishedAt: Date; errorCode?: string; inputTokens?: number; outputTokens?: number }, + ): Promise { + const set: Record = { status: patch.status, finishedAt: patch.finishedAt }; + if (patch.errorCode !== undefined) set.errorCode = patch.errorCode; + if (patch.inputTokens !== undefined) set.inputTokens = patch.inputTokens; + if (patch.outputTokens !== undefined) set.outputTokens = patch.outputTokens; + await AgentRun.updateOne({ _id: id }, { $set: set }); + }, + + async hasNonTerminalRun(sessionId: string): Promise { + return (await AgentRun.exists({ sessionId, status: 'running' })) !== null; + }, + + async listRunsForUser(userId: string): Promise { + return AgentRun.find({ userId }).sort({ createdAt: -1 }).lean(); + }, + + async listAdminRuns(page: number, limit: number): Promise<{ items: AgentRunDoc[]; page: number; limit: number; total: number }> { + const total = await AgentRun.countDocuments(); + const items = await AgentRun.find() + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit) + .lean(); + return { items, page, limit, total }; + }, + + async listNonTerminalRuns(): Promise { + return AgentRun.find({ status: 'running' }).sort({ createdAt: 1 }).lean(); + }, + + async markStaleRuns(olderThan: Date): Promise { + await AgentRun.updateMany( + { status: 'running', leaseUntil: { $lt: olderThan } }, + { $set: { status: 'error', finishedAt: new Date() } }, + ); + }, + + async addSpendToRun(runId: string, inputTokens: number, outputTokens: number): Promise { + await AgentRun.updateOne({ _id: runId }, { $inc: { inputTokens, outputTokens } }); + }, + + async addMessage( + runId: string, + role: 'system' | 'user' | 'assistant' | 'tool', + content: string, + extra?: { toolCallId?: string; name?: string }, + ): Promise { + await AgentMessage.create({ + runId, + role, + content, + toolCallId: extra?.toolCallId, + name: extra?.name, + expiresAt: new Date(Date.now() + TTL_MS), + }); + }, + + async listMessages(runId: string): Promise { + return AgentMessage.find({ runId }).sort({ createdAt: 1 }).lean(); + }, + + async createToolCall(input: { + runId: string; + tool: string; + args: unknown; + outcome: AgentToolOutcome; + latencyMs: number; + result?: unknown; + stagedVersion?: number; + executedVersion?: number; + preImage?: unknown; + approval?: { status: AgentApprovalStatus; summary: string; expiresAt: Date }; + }): Promise { + return AgentToolCall.create({ + runId: input.runId, + tool: input.tool, + args: input.args, + outcome: input.outcome, + latencyMs: input.latencyMs, + result: input.result, + stagedVersion: input.stagedVersion, + executedVersion: input.executedVersion, + preImage: input.preImage, + approval: input.approval, + expiresAt: new Date(Date.now() + TTL_MS), + }); + }, + + async findToolCallById(id: string): Promise { + return AgentToolCall.findById(id).lean(); + }, + + async resolveToolCallApproval( + id: string, + decision: { status: 'approved' | 'rejected'; decidedBy: string }, + ): Promise { + return AgentToolCall.findOneAndUpdate( + { _id: id, 'approval.status': 'pending' }, + { + $set: { + 'approval.status': decision.status, + 'approval.decidedBy': decision.decidedBy, + 'approval.decidedAt': new Date(), + }, + }, + { new: true }, + ).lean(); + }, + + async expireToolCallApproval(id: string): Promise { + await AgentToolCall.updateOne({ _id: id, 'approval.status': 'pending' }, { $set: { 'approval.status': 'expired' } }); + }, + + async expireApprovalsForRuns(runIds: string[]): Promise { + await AgentToolCall.updateMany( + { runId: { $in: runIds }, 'approval.status': 'pending' }, + { $set: { 'approval.status': 'expired' } }, + ); + }, + + async listToolCallsForRun(runId: string): Promise { + return AgentToolCall.find({ runId }).sort({ createdAt: 1 }).lean(); + }, + + async chargeSpend(key: string, usd: number): Promise { + const doc = await AgentSpend.findOneAndUpdate({ key }, { $inc: { spentUsd: usd } }, { upsert: true, new: true }).lean(); + return doc ? doc.spentUsd : usd; + }, + + async getSpend(key: string): Promise { + const doc = await AgentSpend.findOne({ key }).lean(); + return doc?.spentUsd ?? 0; + }, + + async enqueueOutbox(input: { type: string; payloadRef: string }): Promise { + await OutboxEvent.updateOne( + { type: input.type, payloadRef: input.payloadRef }, + { $setOnInsert: { status: 'pending', attempts: 0 } }, + { upsert: true }, + ); + }, + + async claimOutboxEvent(now: Date, leaseMs: number): Promise { + return OutboxEvent.findOneAndUpdate( + { + $or: [{ status: 'pending' }, { status: 'processing', leasedUntil: { $lt: now } }], + }, + { + $set: { status: 'processing', claimedAt: now, leasedUntil: new Date(now.getTime() + leaseMs) }, + $inc: { attempts: 1 }, + }, + { sort: { createdAt: 1 }, new: true }, + ).lean(); + }, + + async completeOutbox(id: string): Promise { + await OutboxEvent.updateOne({ _id: id }, { $set: { status: 'done' } }); + }, + + async failOutbox(id: string): Promise { + await OutboxEvent.updateOne({ _id: id }, { $set: { status: 'failed' } }); + }, + + async listPendingOutboxEvents(): Promise { + const now = new Date(); + return OutboxEvent.find({ + $or: [{ status: 'pending' }, { status: 'processing', leasedUntil: { $lt: now } }], + }) + .sort({ createdAt: 1 }) + .lean(); + }, + + async markOutboxDoneForPayload(type: string, payloadRef: string): Promise { + await OutboxEvent.updateOne({ type, payloadRef }, { $set: { status: 'done' } }); + }, + + async createSuggestion(input: { + workOrderId: string; + runId: string; + summary: string; + suggestedPriority: WorkOrderPriority; + flagForDispatcher: boolean; + applied: boolean; + }): Promise { + return TriageSuggestion.create(input); + }, + + async listSuggestionsForWorkOrder(workOrderId: string): Promise { + return TriageSuggestion.find({ workOrderId }).sort({ createdAt: -1 }).lean(); + }, + + async countSuggestionsToday(): Promise { + const start = new Date(); + start.setHours(0, 0, 0, 0); + return TriageSuggestion.countDocuments({ createdAt: { $gte: start } }); + }, + + async getAgentConfig(name = 'triage'): Promise { + return AgentConfig.findOne({ name }).lean(); + }, + + async upsertAgentConfig(name = 'triage'): Promise { + const doc = await AgentConfig.findOneAndUpdate({ name }, { $setOnInsert: { name } }, { upsert: true, new: true }).lean(); + if (!doc) throw new Error('Failed to upsert agent config'); + return doc; + }, + + async updateAgentConfig( + name: string, + patch: Partial>, + updatedBy: string, + ): Promise { + return AgentConfig.findOneAndUpdate({ name }, { $set: { ...patch, updatedBy } }, { new: true }).lean(); + }, + + async setAgentEnabled(enabled: boolean, updatedBy: string): Promise { + return AgentConfig.findOneAndUpdate({ name: 'triage' }, { $set: { enabled, updatedBy } }, { new: true }).lean(); + }, + + async appendConfigAudit(input: { agentName: string; actorId: string; action: string; before: unknown; after: unknown }): Promise { + await AgentConfigAudit.create(input); + }, +}; \ No newline at end of file diff --git a/backend/src/repositories/user.repo.ts b/backend/src/repositories/user.repo.ts index 773c61e..d914718 100644 --- a/backend/src/repositories/user.repo.ts +++ b/backend/src/repositories/user.repo.ts @@ -122,6 +122,10 @@ export const userRepo = { return User.findByIdAndUpdate(id, { $set: { isActive } }, { new: true }).lean(); }, + async updateAiEnabled(id: string, aiEnabled: boolean): Promise { + return User.findByIdAndUpdate(id, { $set: { aiEnabled } }, { new: true }).lean(); + }, + async updateName(id: string, name: string): Promise { return User.findByIdAndUpdate(id, { $set: { name } }, { new: true }).lean(); }, diff --git a/backend/src/repositories/work-order.repo.ts b/backend/src/repositories/work-order.repo.ts index 8566be4..0be1aab 100644 --- a/backend/src/repositories/work-order.repo.ts +++ b/backend/src/repositories/work-order.repo.ts @@ -1,4 +1,5 @@ import type { WorkOrderPriority, WorkOrderStatus } from '@workorders/shared'; +import { agentRepo } from './agent.repo.js'; import { WorkOrder, type WorkOrderDoc } from '../models/work-order.model.js'; import { signCursor, verifyCursor, type CursorPayload } from '../utils/cursor.js'; import { validation } from '../utils/http-error.js'; @@ -74,7 +75,7 @@ export const workOrderRepo = { priority: WorkOrderPriority; status: WorkOrderStatus; }): Promise { - return WorkOrder.create({ + const doc = await WorkOrder.create({ owner: input.ownerId, title: input.title, description: input.description ?? null, @@ -82,6 +83,8 @@ export const workOrderRepo = { status: input.status, version: 1, }); + await agentRepo.enqueueOutbox({ type: 'work_order.created', payloadRef: doc._id.toString() }); + return doc; }, async findById(id: string): Promise { diff --git a/backend/src/services/admin.service.ts b/backend/src/services/admin.service.ts index a005250..27594cc 100644 --- a/backend/src/services/admin.service.ts +++ b/backend/src/services/admin.service.ts @@ -64,4 +64,14 @@ export const adminService = { const [users, workOrders] = await Promise.all([userRepo.countAll(), workOrderRepo.countAll()]); return { users, workOrders, uptimeSeconds: Math.floor(process.uptime()) }; }, + + async updateAiEnabled(adminId: string, targetId: string, aiEnabled: boolean): Promise { + assertValidObjectId(targetId, 'id'); + if (targetId === adminId) throw forbidden('Cannot change your own AI access'); + const target = await userRepo.findById(targetId); + if (!target) throw notFound(); + const updated = await userRepo.updateAiEnabled(targetId, aiEnabled); + if (!updated) throw notFound(); + return toUserAdmin(updated); + }, }; \ No newline at end of file diff --git a/backend/src/services/work-order.service.ts b/backend/src/services/work-order.service.ts index 2ff8734..18cca14 100644 --- a/backend/src/services/work-order.service.ts +++ b/backend/src/services/work-order.service.ts @@ -1,9 +1,9 @@ -import type { WorkOrderPublic } from '@workorders/shared'; +import type { WorkOrderPriority, WorkOrderPublic } from '@workorders/shared'; import { workOrderRepo, type WorkOrderListQuery } from '../repositories/work-order.repo.js'; import { toWorkOrderPublicWithOwner } from '../models/work-order.model.js'; -import type { Actor } from '../utils/actor.js'; +import { isSystemActor, type Actor } from '../utils/actor.js'; import { assertValidObjectId } from '../utils/object-id.js'; -import { conflictVersion, forbidden, notFound } from '../utils/http-error.js'; +import { conflictVersion, forbidden, notFound, validation } from '../utils/http-error.js'; export interface WorkOrderListResult { items: WorkOrderPublic[]; @@ -34,6 +34,7 @@ export const workOrderService = { }, async get(actor: Actor, id: string): Promise { + if (isSystemActor(actor)) throw forbidden('System actors cannot use this endpoint'); assertValidObjectId(id); const wo = await workOrderRepo.findById(id); if (!wo) throw notFound(); @@ -52,6 +53,7 @@ export const workOrderService = { version: number; }, ): Promise { + if (isSystemActor(actor)) throw forbidden('System actors cannot use this endpoint'); assertWritable(actor); assertValidObjectId(id); const existing = await workOrderRepo.findById(id); @@ -79,6 +81,7 @@ export const workOrderService = { }, async remove(actor: Actor, id: string, version: number): Promise { + if (isSystemActor(actor)) throw forbidden('System actors cannot use this endpoint'); assertWritable(actor); assertValidObjectId(id); const existing = await workOrderRepo.findByIdIncludingDeleted(id); @@ -98,6 +101,26 @@ export const workOrderService = { throw conflictVersion(); } }, + + async triagePatch(actor: Actor, id: string, input: { priority?: WorkOrderPriority }): Promise { + if (!isSystemActor(actor) || actor.capability !== 'triage') throw forbidden('Triage capability required'); + assertValidObjectId(id); + if (input.priority === undefined) throw validation([{ field: 'priority', message: 'priority is required' }]); + const existing = await workOrderRepo.findByIdIncludingDeleted(id); + if (!existing) throw notFound(); + if (existing.deletedAt) throw notFound(); + const updated = await workOrderRepo.updateIfVersion({ + id, + version: existing.version, + patch: { priority: input.priority }, + }); + if (!updated) { + const current = await workOrderRepo.findByIdIncludingDeleted(id); + if (!current || current.deletedAt) throw notFound(); + throw conflictVersion(); + } + return toWorkOrderPublicWithOwner(updated); + }, }; function assertWritable(actor: Actor): void { diff --git a/backend/src/utils/actor.ts b/backend/src/utils/actor.ts index 8addb46..d76124b 100644 --- a/backend/src/utils/actor.ts +++ b/backend/src/utils/actor.ts @@ -1,6 +1,18 @@ import type { Role } from '@workorders/shared'; +export type ActorKind = 'human' | 'system'; + export interface Actor { id: string; role: Role; + kind: ActorKind; + capability?: 'triage'; +} + +export function systemActor(capability: 'triage'): Actor { + return { id: 'system', role: 'user', kind: 'system', capability }; +} + +export function isSystemActor(actor: Actor): boolean { + return actor.kind === 'system'; } \ No newline at end of file