mirror of
https://github.com/vee1e/workorder-desk.git
synced 2026-09-01 09:50:13 +00:00
merge(feat/ai-backend-core): wave 1
This commit is contained in:
commit
84baf39a10
17 changed files with 746 additions and 5 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
28
backend/src/models/agent-config-audit.model.ts
Normal file
28
backend/src/models/agent-config-audit.model.ts
Normal 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>;
|
||||
48
backend/src/models/agent-config.model.ts
Normal file
48
backend/src/models/agent-config.model.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
34
backend/src/models/agent-message.model.ts
Normal file
34
backend/src/models/agent-message.model.ts
Normal 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>;
|
||||
65
backend/src/models/agent-run.model.ts
Normal file
65
backend/src/models/agent-run.model.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
22
backend/src/models/agent-spend.model.ts
Normal file
22
backend/src/models/agent-spend.model.ts
Normal 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>;
|
||||
88
backend/src/models/agent-tool-call.model.ts
Normal file
88
backend/src/models/agent-tool-call.model.ts
Normal 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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
35
backend/src/models/copilot-session.model.ts
Normal file
35
backend/src/models/copilot-session.model.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
35
backend/src/models/outbox-event.model.ts
Normal file
35
backend/src/models/outbox-event.model.ts
Normal 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>;
|
||||
44
backend/src/models/triage-suggestion.model.ts
Normal file
44
backend/src/models/triage-suggestion.model.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
287
backend/src/repositories/agent.repo.ts
Normal file
287
backend/src/repositories/agent.repo.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue