merge(feat/ai-backend-core): wave 1

This commit is contained in:
lakshit verma 2026-08-19 18:24:32 +05:30
commit 84baf39a10
No known key found for this signature in database
17 changed files with 746 additions and 5 deletions

View file

@ -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) {

View file

@ -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<AgentConfigAuditDoc>(
{
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<AgentConfigAuditDoc>;

View file

@ -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<AgentConfigDoc>(
{
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<AgentConfigDoc>;
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(),
};
}

View file

@ -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<AgentMessageDoc>(
{
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<AgentMessageDoc>;

View file

@ -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<AgentRunDoc>(
{
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<AgentRunDoc>;
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 } : {}),
};
}

View file

@ -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<AgentSpendDoc>(
{
key: { type: String, required: true, unique: true },
spentUsd: { type: Number, default: 0 },
},
{ timestamps: true },
);
export const AgentSpend = (models.AgentSpend ?? model('AgentSpend', agentSpendSchema)) as Model<AgentSpendDoc>;

View file

@ -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<AgentToolCallDoc>(
{
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<AgentToolCallDoc>;
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,
}
: {}),
};
}

View file

@ -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<CopilotSessionDoc>(
{
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<CopilotSessionDoc>;
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(),
};
}

View file

@ -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<OutboxEventDoc>(
{
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<OutboxEventDoc>;

View file

@ -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<TriageSuggestionDoc>(
{
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<TriageSuggestionDoc>;
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(),
};
}

View file

@ -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<UserDoc>(
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,
};
}

View file

@ -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<CopilotSessionDoc> {
return CopilotSession.create({ userId });
},
async findSessionById(id: string): Promise<CopilotSessionDoc | null> {
return CopilotSession.findById(id).lean();
},
async listSessionsForUser(userId: string): Promise<CopilotSessionDoc[]> {
return CopilotSession.find({ userId }).sort({ createdAt: -1 }).lean();
},
async archiveSession(id: string): Promise<void> {
await CopilotSession.updateOne({ _id: id }, { $set: { status: 'archived' } });
},
async setSessionStatus(id: string, status: CopilotSessionStatus): Promise<void> {
await CopilotSession.updateOne({ _id: id }, { $set: { status } });
},
async createRun(input: {
sessionId: string;
userId: string;
mode: AgentRunMode;
agentName?: string;
model: string;
}): Promise<AgentRunDoc> {
return AgentRun.create({
sessionId: input.sessionId,
userId: input.userId,
mode: input.mode,
agentName: input.agentName,
model: input.model,
});
},
async findRunById(id: string): Promise<AgentRunDoc | null> {
return AgentRun.findById(id).lean();
},
async finishRun(
id: string,
patch: { status: AgentRunStatus; finishedAt: Date; errorCode?: string; inputTokens?: number; outputTokens?: number },
): Promise<void> {
const set: Record<string, unknown> = { 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<boolean> {
return (await AgentRun.exists({ sessionId, status: 'running' })) !== null;
},
async listRunsForUser(userId: string): Promise<AgentRunDoc[]> {
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<AgentRunDoc[]> {
return AgentRun.find({ status: 'running' }).sort({ createdAt: 1 }).lean();
},
async markStaleRuns(olderThan: Date): Promise<void> {
await AgentRun.updateMany(
{ status: 'running', leaseUntil: { $lt: olderThan } },
{ $set: { status: 'error', finishedAt: new Date() } },
);
},
async addSpendToRun(runId: string, inputTokens: number, outputTokens: number): Promise<void> {
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<void> {
await AgentMessage.create({
runId,
role,
content,
toolCallId: extra?.toolCallId,
name: extra?.name,
expiresAt: new Date(Date.now() + TTL_MS),
});
},
async listMessages(runId: string): Promise<AgentMessageDoc[]> {
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<AgentToolCallDoc> {
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<AgentToolCallDoc | null> {
return AgentToolCall.findById(id).lean();
},
async resolveToolCallApproval(
id: string,
decision: { status: 'approved' | 'rejected'; decidedBy: string },
): Promise<AgentToolCallDoc | null> {
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<void> {
await AgentToolCall.updateOne({ _id: id, 'approval.status': 'pending' }, { $set: { 'approval.status': 'expired' } });
},
async expireApprovalsForRuns(runIds: string[]): Promise<void> {
await AgentToolCall.updateMany(
{ runId: { $in: runIds }, 'approval.status': 'pending' },
{ $set: { 'approval.status': 'expired' } },
);
},
async listToolCallsForRun(runId: string): Promise<AgentToolCallDoc[]> {
return AgentToolCall.find({ runId }).sort({ createdAt: 1 }).lean();
},
async chargeSpend(key: string, usd: number): Promise<number> {
const doc = await AgentSpend.findOneAndUpdate({ key }, { $inc: { spentUsd: usd } }, { upsert: true, new: true }).lean();
return doc ? doc.spentUsd : usd;
},
async getSpend(key: string): Promise<number> {
const doc = await AgentSpend.findOne({ key }).lean();
return doc?.spentUsd ?? 0;
},
async enqueueOutbox(input: { type: string; payloadRef: string }): Promise<void> {
await OutboxEvent.updateOne(
{ type: input.type, payloadRef: input.payloadRef },
{ $setOnInsert: { status: 'pending', attempts: 0 } },
{ upsert: true },
);
},
async claimOutboxEvent(now: Date, leaseMs: number): Promise<OutboxEventDoc | null> {
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<void> {
await OutboxEvent.updateOne({ _id: id }, { $set: { status: 'done' } });
},
async failOutbox(id: string): Promise<void> {
await OutboxEvent.updateOne({ _id: id }, { $set: { status: 'failed' } });
},
async listPendingOutboxEvents(): Promise<OutboxEventDoc[]> {
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<void> {
await OutboxEvent.updateOne({ type, payloadRef }, { $set: { status: 'done' } });
},
async createSuggestion(input: {
workOrderId: string;
runId: string;
summary: string;
suggestedPriority: WorkOrderPriority;
flagForDispatcher: boolean;
applied: boolean;
}): Promise<TriageSuggestionDoc> {
return TriageSuggestion.create(input);
},
async listSuggestionsForWorkOrder(workOrderId: string): Promise<TriageSuggestionDoc[]> {
return TriageSuggestion.find({ workOrderId }).sort({ createdAt: -1 }).lean();
},
async countSuggestionsToday(): Promise<number> {
const start = new Date();
start.setHours(0, 0, 0, 0);
return TriageSuggestion.countDocuments({ createdAt: { $gte: start } });
},
async getAgentConfig(name = 'triage'): Promise<AgentConfigDoc | null> {
return AgentConfig.findOne({ name }).lean();
},
async upsertAgentConfig(name = 'triage'): Promise<AgentConfigDoc> {
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<Pick<AgentConfigDoc, 'mode' | 'allowedFields' | 'dailyActionCap' | 'flagThreshold' | 'workingHours'>>,
updatedBy: string,
): Promise<AgentConfigDoc | null> {
return AgentConfig.findOneAndUpdate({ name }, { $set: { ...patch, updatedBy } }, { new: true }).lean();
},
async setAgentEnabled(enabled: boolean, updatedBy: string): Promise<AgentConfigDoc | null> {
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<void> {
await AgentConfigAudit.create(input);
},
};

View file

@ -122,6 +122,10 @@ export const userRepo = {
return User.findByIdAndUpdate(id, { $set: { isActive } }, { new: true }).lean();
},
async updateAiEnabled(id: string, aiEnabled: boolean): Promise<UserDoc | null> {
return User.findByIdAndUpdate(id, { $set: { aiEnabled } }, { new: true }).lean();
},
async updateName(id: string, name: string): Promise<UserDoc | null> {
return User.findByIdAndUpdate(id, { $set: { name } }, { new: true }).lean();
},

View file

@ -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<WorkOrderDoc> {
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<WorkOrderDoc | null> {

View file

@ -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<UserAdmin> {
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);
},
};

View file

@ -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<WorkOrderPublic> {
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<WorkOrderPublic> {
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<void> {
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<WorkOrderPublic> {
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 {

View file

@ -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';
}