@lota-sdk/core 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/infrastructure/schema/00_identity.surql +0 -2
- package/infrastructure/schema/01_memory.surql +1 -1
- package/infrastructure/schema/02_execution_plan.surql +62 -1
- package/infrastructure/schema/03_learned_skill.surql +1 -1
- package/infrastructure/schema/06_playbook.surql +25 -0
- package/infrastructure/schema/07_institutional_memory.surql +13 -0
- package/infrastructure/schema/08_quality_metrics.surql +17 -0
- package/package.json +9 -8
- package/src/ai/definitions.ts +80 -2
- package/src/ai/embedding-cache.ts +7 -6
- package/src/ai/index.ts +0 -1
- package/src/bifrost/bifrost.ts +14 -14
- package/src/config/agent-defaults.ts +32 -22
- package/src/config/agent-types.ts +11 -0
- package/src/config/constants.ts +2 -14
- package/src/config/debug-logger.ts +5 -1
- package/src/config/index.ts +3 -0
- package/src/config/logger.ts +7 -9
- package/src/config/model-constants.ts +16 -34
- package/src/config/search.ts +1 -15
- package/src/create-runtime.ts +453 -0
- package/src/db/cursor-pagination.ts +3 -6
- package/src/db/index.ts +2 -0
- package/src/db/memory-store.rows.ts +7 -7
- package/src/db/memory-store.ts +24 -24
- package/src/db/memory.ts +18 -16
- package/src/db/schema-fingerprint.ts +1 -0
- package/src/db/service.ts +193 -122
- package/src/db/startup.ts +9 -13
- package/src/db/surreal-mutation.ts +43 -0
- package/src/db/tables.ts +7 -0
- package/src/db/workstream-message-row.ts +15 -0
- package/src/embeddings/provider.ts +1 -1
- package/src/index.ts +1 -1
- package/src/queues/context-compaction.queue.ts +17 -52
- package/src/queues/delayed-node-promotion.queue.ts +41 -0
- package/src/queues/document-processor.queue.ts +7 -7
- package/src/queues/index.ts +3 -0
- package/src/queues/memory-consolidation.queue.ts +18 -54
- package/src/queues/plan-scheduler.queue.ts +97 -0
- package/src/queues/post-chat-memory.queue.ts +15 -60
- package/src/queues/queue-factory.ts +100 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +15 -54
- package/src/queues/regular-chat-memory-digest.queue.ts +16 -55
- package/src/queues/skill-extraction.queue.ts +15 -50
- package/src/queues/workstream-title-generation.queue.ts +15 -51
- package/src/redis/connection.ts +12 -3
- package/src/redis/index.ts +2 -1
- package/src/redis/org-memory-lock.ts +1 -1
- package/src/redis/redis-lease-lock.ts +41 -8
- package/src/redis/stream-context.ts +11 -0
- package/src/runtime/agent-runtime-policy.ts +106 -21
- package/src/runtime/agent-stream-helpers.ts +2 -1
- package/src/runtime/approval-continuation.ts +12 -6
- package/src/runtime/context-compaction-constants.ts +1 -1
- package/src/runtime/context-compaction-runtime.ts +7 -5
- package/src/runtime/context-compaction.ts +40 -97
- package/src/runtime/execution-plan.ts +23 -19
- package/src/runtime/graph-designer.ts +15 -0
- package/src/runtime/helper-model.ts +10 -196
- package/src/runtime/index.ts +14 -1
- package/src/runtime/llm-content.ts +1 -1
- package/src/runtime/memory-block.ts +11 -12
- package/src/runtime/memory-pipeline.ts +26 -10
- package/src/runtime/plugin-resolution.ts +35 -0
- package/src/runtime/plugin-types.ts +73 -1
- package/src/runtime/retrieval-adapters.ts +1 -1
- package/src/runtime/runtime-config.ts +25 -12
- package/src/runtime/runtime-extensions.ts +91 -15
- package/src/runtime/runtime-worker-registry.ts +6 -0
- package/src/runtime/team-consultation-orchestrator.ts +45 -28
- package/src/runtime/team-consultation-prompts.ts +11 -2
- package/src/runtime/title-helpers.ts +11 -4
- package/src/runtime/workstream-chat-helpers.ts +6 -7
- package/src/runtime/workstream-routing-policy.ts +0 -30
- package/src/runtime/workstream-state.ts +17 -7
- package/src/services/adaptive-playbook.service.ts +152 -0
- package/src/services/agent-executor.service.ts +293 -0
- package/src/services/artifact-provenance.service.ts +172 -0
- package/src/services/attachment.service.ts +7 -12
- package/src/services/context-compaction.service.ts +75 -58
- package/src/services/context-enrichment.service.ts +33 -0
- package/src/services/coordination-registry.service.ts +117 -0
- package/src/services/document-chunk.service.ts +38 -33
- package/src/services/domain-agent-executor.service.ts +71 -0
- package/src/services/execution-plan.service.ts +271 -50
- package/src/services/feedback-loop.service.ts +96 -0
- package/src/services/global-orchestrator.service.ts +148 -0
- package/src/services/index.ts +26 -0
- package/src/services/institutional-memory.service.ts +145 -0
- package/src/services/learned-skill.service.ts +30 -15
- package/src/services/memory-assessment.service.ts +3 -2
- package/src/services/{memory.utils.ts → memory-utils.ts} +4 -13
- package/src/services/memory.service.ts +55 -69
- package/src/services/monitoring-window.service.ts +86 -0
- package/src/services/mutating-approval.service.ts +1 -1
- package/src/services/node-workspace.service.ts +155 -0
- package/src/services/notification.service.ts +39 -0
- package/src/services/organization-member.service.ts +12 -5
- package/src/services/organization.service.ts +5 -5
- package/src/services/ownership-dispatcher.service.ts +403 -0
- package/src/services/plan-approval.service.ts +1 -1
- package/src/services/plan-artifact.service.ts +1 -0
- package/src/services/plan-builder.service.ts +1 -0
- package/src/services/plan-checkpoint.service.ts +30 -2
- package/src/services/plan-compiler.service.ts +5 -0
- package/src/services/plan-coordination.service.ts +152 -0
- package/src/services/plan-cycle.service.ts +284 -0
- package/src/services/plan-deadline.service.ts +287 -0
- package/src/services/plan-executor.service.ts +386 -58
- package/src/services/plan-helpers.ts +15 -0
- package/src/services/plan-run.service.ts +41 -7
- package/src/services/plan-scheduler.service.ts +240 -0
- package/src/services/plan-template.service.ts +117 -0
- package/src/services/plan-validator.service.ts +87 -20
- package/src/services/plan-workspace.service.ts +83 -0
- package/src/services/playbook-registry.service.ts +67 -0
- package/src/services/plugin-executor.service.ts +103 -0
- package/src/services/quality-metrics.service.ts +132 -0
- package/src/services/recent-activity-title.service.ts +3 -10
- package/src/services/recent-activity.service.ts +33 -43
- package/src/services/skill-resolver.service.ts +19 -0
- package/src/services/system-executor.service.ts +105 -0
- package/src/services/workstream-message.service.ts +29 -41
- package/src/services/workstream-plan-registry.service.ts +22 -0
- package/src/services/workstream-title.service.ts +3 -9
- package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +428 -373
- package/src/services/workstream-turn.ts +2 -2
- package/src/services/workstream.service.ts +55 -65
- package/src/services/workstream.types.ts +10 -19
- package/src/services/write-intent-validator.service.ts +81 -0
- package/src/storage/attachment-parser.ts +1 -1
- package/src/storage/attachment-storage.service.ts +4 -4
- package/src/storage/{attachments.utils.ts → attachment-utils.ts} +2 -5
- package/src/storage/generated-document-storage.service.ts +3 -2
- package/src/storage/index.ts +2 -2
- package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
- package/src/system-agents/delegated-agent-factory.ts +5 -2
- package/src/system-agents/index.ts +8 -0
- package/src/system-agents/memory-reranker.agent.ts +1 -1
- package/src/system-agents/memory.agent.ts +1 -1
- package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
- package/src/tools/execution-plan.tool.ts +17 -19
- package/src/tools/fetch-webpage.tool.ts +20 -18
- package/src/tools/index.ts +2 -3
- package/src/tools/read-file-parts.tool.ts +1 -1
- package/src/tools/search-web.tool.ts +18 -15
- package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
- package/src/tools/team-think.tool.ts +14 -8
- package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
- package/src/utils/async.ts +3 -2
- package/src/utils/date-time.ts +4 -32
- package/src/utils/env.ts +8 -0
- package/src/utils/errors.ts +47 -0
- package/src/utils/hono-error-handler.ts +1 -2
- package/src/utils/index.ts +19 -2
- package/src/utils/string.ts +128 -1
- package/src/workers/bootstrap.ts +2 -2
- package/src/workers/index.ts +1 -0
- package/src/workers/memory-consolidation.worker.ts +12 -12
- package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
- package/src/workers/regular-chat-memory-digest.runner.ts +11 -105
- package/src/workers/skill-extraction.runner.ts +8 -102
- package/src/workers/utils/file-section-chunker.ts +6 -3
- package/src/workers/utils/repomix-file-sections.ts +2 -2
- package/src/workers/utils/sandbox-error.ts +11 -2
- package/src/workers/utils/workstream-message-query.ts +97 -0
- package/src/workers/worker-utils.ts +6 -2
- package/src/runtime/retrieval-pipeline.ts +0 -3
- package/src/runtime.ts +0 -387
- package/src/tools/log-hello-world.tool.ts +0 -17
- package/src/utils/error.ts +0 -10
- /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
- /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { PlanArtifactSchema, PlanCycleRecordSchema, PlanRunSchema } from '@lota-sdk/shared'
|
|
2
|
+
import type {
|
|
3
|
+
CarryForwardPolicy,
|
|
4
|
+
CycleSchedule,
|
|
5
|
+
PlanArtifactRecord,
|
|
6
|
+
PlanCycleRecord,
|
|
7
|
+
PlanDraft,
|
|
8
|
+
PlanRunStatus,
|
|
9
|
+
PlanScheduleSpec,
|
|
10
|
+
PlanTemplateRecord,
|
|
11
|
+
} from '@lota-sdk/shared'
|
|
12
|
+
|
|
13
|
+
import type { RecordIdInput } from '../db/record-id'
|
|
14
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
15
|
+
import { databaseService } from '../db/service'
|
|
16
|
+
import { TABLES } from '../db/tables'
|
|
17
|
+
import { planSchedulerService } from './plan-scheduler.service'
|
|
18
|
+
import { planTemplateService } from './plan-template.service'
|
|
19
|
+
|
|
20
|
+
const TERMINAL_RUN_STATUSES: ReadonlySet<PlanRunStatus> = new Set(['completed', 'failed', 'aborted'])
|
|
21
|
+
|
|
22
|
+
function cycleScheduleToSpec(schedule: CycleSchedule): PlanScheduleSpec {
|
|
23
|
+
const startAt = schedule.startAt ? new Date(schedule.startAt) : undefined
|
|
24
|
+
const isFutureStart = startAt && startAt.getTime() > Date.now()
|
|
25
|
+
|
|
26
|
+
if (isFutureStart) {
|
|
27
|
+
return { type: 'absolute', at: startAt.toISOString(), missedPolicy: 'run-immediately' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cron = frequencyToCron(schedule.frequency, schedule.customIntervalMs)
|
|
31
|
+
if (cron) {
|
|
32
|
+
return { type: 'cron', cron, missedPolicy: 'run-immediately' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (schedule.frequency === 'biweekly') {
|
|
36
|
+
return { type: 'monitoring', intervalMs: 14 * 24 * 60 * 60 * 1000, missedPolicy: 'run-immediately' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (schedule.frequency === 'custom' && schedule.customIntervalMs) {
|
|
40
|
+
return { type: 'monitoring', intervalMs: schedule.customIntervalMs, missedPolicy: 'run-immediately' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { type: 'cron', cron: '0 0 * * *', missedPolicy: 'run-immediately' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function frequencyToCron(frequency: CycleSchedule['frequency'], _customIntervalMs?: number): string | null {
|
|
47
|
+
switch (frequency) {
|
|
48
|
+
case 'daily':
|
|
49
|
+
return '0 0 * * *'
|
|
50
|
+
case 'weekly':
|
|
51
|
+
return '0 0 * * 1'
|
|
52
|
+
case 'monthly':
|
|
53
|
+
return '0 0 1 * *'
|
|
54
|
+
case 'quarterly':
|
|
55
|
+
return '0 0 1 */3 *'
|
|
56
|
+
case 'biweekly':
|
|
57
|
+
case 'custom':
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class PlanCycleService {
|
|
63
|
+
cycleScheduleToSpec = cycleScheduleToSpec
|
|
64
|
+
|
|
65
|
+
async createCycle(params: {
|
|
66
|
+
organizationId: RecordIdInput
|
|
67
|
+
workstreamId: RecordIdInput
|
|
68
|
+
templateId: RecordIdInput
|
|
69
|
+
name: string
|
|
70
|
+
schedule: CycleSchedule
|
|
71
|
+
carryForwardPolicy?: CarryForwardPolicy
|
|
72
|
+
leadAgentId: string
|
|
73
|
+
}): Promise<PlanCycleRecord> {
|
|
74
|
+
const now = new Date()
|
|
75
|
+
|
|
76
|
+
const cycle = await databaseService.create(
|
|
77
|
+
TABLES.PLAN_CYCLE,
|
|
78
|
+
{
|
|
79
|
+
organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
|
|
80
|
+
workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
|
|
81
|
+
templateId: ensureRecordId(params.templateId, TABLES.PLAN_TEMPLATE),
|
|
82
|
+
name: params.name,
|
|
83
|
+
schedule: params.schedule,
|
|
84
|
+
carryForwardPolicy: params.carryForwardPolicy ?? 'incomplete-only',
|
|
85
|
+
status: 'active',
|
|
86
|
+
currentIteration: 0,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
},
|
|
89
|
+
PlanCycleRecordSchema,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const createResult = await planTemplateService.instantiate({
|
|
93
|
+
templateId: params.templateId,
|
|
94
|
+
organizationId: params.organizationId,
|
|
95
|
+
workstreamId: params.workstreamId,
|
|
96
|
+
leadAgentId: params.leadAgentId,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const createdRunId = createResult.plan?.runId
|
|
100
|
+
|
|
101
|
+
const scheduleSpec = cycleScheduleToSpec(params.schedule)
|
|
102
|
+
const scheduleRecord = await planSchedulerService.createSchedule({
|
|
103
|
+
organizationId: params.organizationId,
|
|
104
|
+
workstreamId: params.workstreamId,
|
|
105
|
+
...(createdRunId ? { runId: ensureRecordId(createdRunId, TABLES.PLAN_RUN) } : {}),
|
|
106
|
+
scheduleSpec,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const updated = await databaseService.update(
|
|
110
|
+
TABLES.PLAN_CYCLE,
|
|
111
|
+
ensureRecordId(cycle.id, TABLES.PLAN_CYCLE),
|
|
112
|
+
{
|
|
113
|
+
scheduleId: ensureRecordId(scheduleRecord.id, TABLES.PLAN_SCHEDULE),
|
|
114
|
+
...(createdRunId ? { currentRunId: ensureRecordId(createdRunId, TABLES.PLAN_RUN) } : {}),
|
|
115
|
+
currentIteration: 1,
|
|
116
|
+
},
|
|
117
|
+
PlanCycleRecordSchema,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return updated ?? cycle
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async advanceCycle(cycleId: RecordIdInput): Promise<void> {
|
|
124
|
+
const cycle = await this.getCycle(cycleId)
|
|
125
|
+
if (!cycle) {
|
|
126
|
+
throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (cycle.status !== 'active') {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (cycle.currentRunId) {
|
|
134
|
+
const runRecord = await databaseService.findOne(
|
|
135
|
+
TABLES.PLAN_RUN,
|
|
136
|
+
{ id: ensureRecordId(cycle.currentRunId, TABLES.PLAN_RUN) },
|
|
137
|
+
PlanRunSchema,
|
|
138
|
+
)
|
|
139
|
+
if (runRecord && !TERMINAL_RUN_STATUSES.has(runRecord.status)) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const template = await planTemplateService.getTemplate(cycle.templateId)
|
|
145
|
+
if (!template) {
|
|
146
|
+
throw new Error(`Template not found for cycle: ${recordIdToString(cycle.templateId, TABLES.PLAN_TEMPLATE)}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const previousRunArtifacts = cycle.currentRunId
|
|
150
|
+
? await databaseService.findMany(
|
|
151
|
+
TABLES.PLAN_ARTIFACT,
|
|
152
|
+
{ runId: ensureRecordId(cycle.currentRunId, TABLES.PLAN_RUN) },
|
|
153
|
+
PlanArtifactSchema,
|
|
154
|
+
{ orderBy: 'createdAt', orderDir: 'ASC' },
|
|
155
|
+
)
|
|
156
|
+
: []
|
|
157
|
+
|
|
158
|
+
const draft = this.buildCarryForwardDraft({ template, previousRunArtifacts, policy: cycle.carryForwardPolicy })
|
|
159
|
+
|
|
160
|
+
const result = await planTemplateService.instantiate({
|
|
161
|
+
templateId: cycle.templateId,
|
|
162
|
+
organizationId: cycle.organizationId,
|
|
163
|
+
workstreamId: cycle.workstreamId,
|
|
164
|
+
leadAgentId: 'system',
|
|
165
|
+
overrides: draft,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const newRunId = result.plan?.runId
|
|
169
|
+
|
|
170
|
+
await databaseService.update(
|
|
171
|
+
TABLES.PLAN_CYCLE,
|
|
172
|
+
ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
|
|
173
|
+
{
|
|
174
|
+
...(newRunId ? { currentRunId: ensureRecordId(newRunId, TABLES.PLAN_RUN) } : {}),
|
|
175
|
+
currentIteration: cycle.currentIteration + 1,
|
|
176
|
+
},
|
|
177
|
+
PlanCycleRecordSchema,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
// TODO: re-enable when playbook records exist and IDs align
|
|
181
|
+
// The previous implementation passed a PLAN_TEMPLATE record ID as a PLAYBOOK ID,
|
|
182
|
+
// causing refineFromCycle to always fail silently.
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
buildCarryForwardDraft(params: {
|
|
186
|
+
template: PlanTemplateRecord
|
|
187
|
+
previousRunArtifacts: PlanArtifactRecord[]
|
|
188
|
+
policy: CarryForwardPolicy
|
|
189
|
+
}): PlanDraft {
|
|
190
|
+
const draft = { ...params.template.draft }
|
|
191
|
+
|
|
192
|
+
if (params.policy === 'none' || params.previousRunArtifacts.length === 0) {
|
|
193
|
+
return draft
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let artifacts: PlanArtifactRecord[]
|
|
197
|
+
if (params.policy === 'incomplete-only') {
|
|
198
|
+
artifacts = params.previousRunArtifacts.filter((a) => a.kind === 'markdown' || a.kind === 'json')
|
|
199
|
+
} else {
|
|
200
|
+
// 'all-pending'
|
|
201
|
+
artifacts = [...params.previousRunArtifacts]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (artifacts.length > 0) {
|
|
205
|
+
const carryContext = artifacts.map((a) => `[carry-forward] ${a.name}: ${a.pointer}`)
|
|
206
|
+
draft.objective = `${draft.objective}\n\nCarry-forward context:\n${carryContext.join('\n')}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return draft
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async cancelCycle(cycleId: RecordIdInput): Promise<void> {
|
|
213
|
+
const cycle = await this.getCycle(cycleId)
|
|
214
|
+
if (!cycle) {
|
|
215
|
+
throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (cycle.scheduleId) {
|
|
219
|
+
await planSchedulerService.cancelSchedule(cycle.scheduleId)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await databaseService.update(
|
|
223
|
+
TABLES.PLAN_CYCLE,
|
|
224
|
+
ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
|
|
225
|
+
{ status: 'cancelled' },
|
|
226
|
+
PlanCycleRecordSchema,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async pauseCycle(cycleId: RecordIdInput): Promise<void> {
|
|
231
|
+
const cycle = await this.getCycle(cycleId)
|
|
232
|
+
if (!cycle) {
|
|
233
|
+
throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (cycle.scheduleId) {
|
|
237
|
+
await planSchedulerService.pauseSchedule(cycle.scheduleId)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await databaseService.update(
|
|
241
|
+
TABLES.PLAN_CYCLE,
|
|
242
|
+
ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
|
|
243
|
+
{ status: 'paused' },
|
|
244
|
+
PlanCycleRecordSchema,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async resumeCycle(cycleId: RecordIdInput): Promise<void> {
|
|
249
|
+
const cycle = await this.getCycle(cycleId)
|
|
250
|
+
if (!cycle) {
|
|
251
|
+
throw new Error(`Cycle not found: ${recordIdToString(cycleId, TABLES.PLAN_CYCLE)}`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (cycle.scheduleId) {
|
|
255
|
+
await planSchedulerService.resumeSchedule(cycle.scheduleId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await databaseService.update(
|
|
259
|
+
TABLES.PLAN_CYCLE,
|
|
260
|
+
ensureRecordId(cycleId, TABLES.PLAN_CYCLE),
|
|
261
|
+
{ status: 'active' },
|
|
262
|
+
PlanCycleRecordSchema,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async listCycles(workstreamId: RecordIdInput): Promise<PlanCycleRecord[]> {
|
|
267
|
+
return databaseService.findMany(
|
|
268
|
+
TABLES.PLAN_CYCLE,
|
|
269
|
+
{ workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
|
|
270
|
+
PlanCycleRecordSchema,
|
|
271
|
+
{ orderBy: 'createdAt', orderDir: 'ASC' },
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async getCycle(cycleId: RecordIdInput): Promise<PlanCycleRecord | null> {
|
|
276
|
+
return databaseService.findOne(
|
|
277
|
+
TABLES.PLAN_CYCLE,
|
|
278
|
+
{ id: ensureRecordId(cycleId, TABLES.PLAN_CYCLE) },
|
|
279
|
+
PlanCycleRecordSchema,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const planCycleService = new PlanCycleService()
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeadlineAction,
|
|
3
|
+
DeadlineReminder,
|
|
4
|
+
DeadlineSpec,
|
|
5
|
+
PlanNodeRunRecord,
|
|
6
|
+
PlanNodeSpecRecord,
|
|
7
|
+
PlanRunRecord,
|
|
8
|
+
} from '@lota-sdk/shared'
|
|
9
|
+
import { PlanNodeRunSchema, PlanNodeSpecRecordSchema, PlanRunSchema } from '@lota-sdk/shared'
|
|
10
|
+
|
|
11
|
+
import type { RecordIdInput } from '../db/record-id'
|
|
12
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
13
|
+
import { databaseService } from '../db/service'
|
|
14
|
+
import { TABLES } from '../db/tables'
|
|
15
|
+
import { getNotificationService } from './notification.service'
|
|
16
|
+
|
|
17
|
+
export type DeadlineEvaluationStatus = 'ok' | 'warning' | 'escalated' | 'missed'
|
|
18
|
+
|
|
19
|
+
export interface DeadlineEvaluationResult {
|
|
20
|
+
status: DeadlineEvaluationStatus
|
|
21
|
+
activeReminder?: DeadlineReminder
|
|
22
|
+
nextTriggerAt?: Date | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class PlanDeadlineService {
|
|
26
|
+
evaluateDeadline(params: { deadline: DeadlineSpec; nodeStartedAt: Date; now?: Date }): DeadlineEvaluationResult {
|
|
27
|
+
const now = params.now ?? new Date()
|
|
28
|
+
const deadlineTime = this.resolveDeadlineTime(params.deadline, params.nodeStartedAt)
|
|
29
|
+
|
|
30
|
+
if (!deadlineTime) {
|
|
31
|
+
return { status: 'ok', nextTriggerAt: null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const reminderEntries = [...params.deadline.reminders]
|
|
35
|
+
.map((reminder) => ({ reminder, triggerAt: new Date(deadlineTime.getTime() - reminder.beforeMs) }))
|
|
36
|
+
.sort((a, b) => a.triggerAt.getTime() - b.triggerAt.getTime())
|
|
37
|
+
|
|
38
|
+
let activeReminder: DeadlineReminder | undefined
|
|
39
|
+
let nextTriggerAt: Date | null = null
|
|
40
|
+
|
|
41
|
+
for (const entry of reminderEntries) {
|
|
42
|
+
if (now.getTime() < entry.triggerAt.getTime()) {
|
|
43
|
+
nextTriggerAt = entry.triggerAt
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
activeReminder = entry.reminder
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (now.getTime() >= deadlineTime.getTime()) {
|
|
51
|
+
return { status: 'missed', nextTriggerAt: null }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (activeReminder) {
|
|
55
|
+
const status: DeadlineEvaluationStatus = activeReminder.action === 'escalate' ? 'escalated' : 'warning'
|
|
56
|
+
return { status, activeReminder, nextTriggerAt: nextTriggerAt ?? deadlineTime }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { status: 'ok', nextTriggerAt: nextTriggerAt ?? deadlineTime }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async checkDeadlines(now?: Date): Promise<void> {
|
|
63
|
+
const currentTime = now ?? new Date()
|
|
64
|
+
|
|
65
|
+
const sweep = await this.collectDeadlineSweep(currentTime)
|
|
66
|
+
if (sweep.entries.length === 0) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Query parent runs lazily only for node runs that need notification or escalation handling.
|
|
71
|
+
const runCache = new Map<string, PlanRunRecord>()
|
|
72
|
+
|
|
73
|
+
for (const entry of sweep.entries) {
|
|
74
|
+
if (entry.evaluation.status === 'ok') continue
|
|
75
|
+
|
|
76
|
+
const deadline = entry.nodeSpec.deadline
|
|
77
|
+
if (!deadline) continue
|
|
78
|
+
|
|
79
|
+
const runIdStr = recordIdToString(entry.nodeRun.runId, TABLES.PLAN_RUN)
|
|
80
|
+
let run = runCache.get(runIdStr)
|
|
81
|
+
if (!run) {
|
|
82
|
+
const found = await databaseService.findOne(
|
|
83
|
+
TABLES.PLAN_RUN,
|
|
84
|
+
{ id: ensureRecordId(entry.nodeRun.runId, TABLES.PLAN_RUN) },
|
|
85
|
+
PlanRunSchema,
|
|
86
|
+
)
|
|
87
|
+
if (!found) continue
|
|
88
|
+
run = found
|
|
89
|
+
runCache.set(runIdStr, run)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
|
|
93
|
+
const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
|
|
94
|
+
const notificationService = getNotificationService()
|
|
95
|
+
const dedupeKeyBase = `plan-deadline:${runIdStr}:${entry.nodeRun.nodeId}`
|
|
96
|
+
|
|
97
|
+
if (entry.evaluation.status === 'warning') {
|
|
98
|
+
await notificationService.remind({
|
|
99
|
+
organizationId,
|
|
100
|
+
workstreamId,
|
|
101
|
+
runId: runIdStr,
|
|
102
|
+
nodeId: entry.nodeRun.nodeId,
|
|
103
|
+
severity: 'warning',
|
|
104
|
+
title: 'Deadline approaching',
|
|
105
|
+
body:
|
|
106
|
+
entry.evaluation.activeReminder?.message ?? `Node "${entry.nodeRun.nodeId}" is approaching its deadline.`,
|
|
107
|
+
dedupeKey: `${dedupeKeyBase}:warning:${entry.evaluation.activeReminder?.beforeMs ?? 'default'}`,
|
|
108
|
+
})
|
|
109
|
+
} else if (entry.evaluation.status === 'escalated') {
|
|
110
|
+
await notificationService.escalate({
|
|
111
|
+
organizationId,
|
|
112
|
+
workstreamId,
|
|
113
|
+
runId: runIdStr,
|
|
114
|
+
nodeId: entry.nodeRun.nodeId,
|
|
115
|
+
severity: 'urgent',
|
|
116
|
+
title: 'Deadline escalation',
|
|
117
|
+
body:
|
|
118
|
+
entry.evaluation.activeReminder?.message ?? `Node "${entry.nodeRun.nodeId}" deadline requires escalation.`,
|
|
119
|
+
dedupeKey: `${dedupeKeyBase}:escalated:${entry.evaluation.activeReminder?.beforeMs ?? 'default'}`,
|
|
120
|
+
})
|
|
121
|
+
} else {
|
|
122
|
+
await this.applyDeadlineMissAction({
|
|
123
|
+
runId: entry.nodeRun.runId,
|
|
124
|
+
nodeId: entry.nodeRun.nodeId,
|
|
125
|
+
workstreamId,
|
|
126
|
+
organizationId,
|
|
127
|
+
action: deadline.missAction,
|
|
128
|
+
emittedBy: 'plan-deadline-checker',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const nextTriggerAt: Date | null =
|
|
134
|
+
sweep.entries
|
|
135
|
+
.map((entry) => entry.evaluation.nextTriggerAt)
|
|
136
|
+
.filter((value): value is Date => {
|
|
137
|
+
if (!value) return false
|
|
138
|
+
return value.getTime() > currentTime.getTime()
|
|
139
|
+
})
|
|
140
|
+
.sort((a, b) => a.getTime() - b.getTime())[0] ?? null
|
|
141
|
+
|
|
142
|
+
if (nextTriggerAt) {
|
|
143
|
+
await this.enqueueDeadlineCheck(nextTriggerAt)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async recoverDeadlineChecks(now = new Date()): Promise<void> {
|
|
148
|
+
const sweep = await this.collectDeadlineSweep(now)
|
|
149
|
+
const hasDueAction = sweep.entries.some((entry) => entry.evaluation.status !== 'ok')
|
|
150
|
+
if (hasDueAction) {
|
|
151
|
+
await this.enqueueDeadlineCheck(now)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const nextTriggerAt: Date | null =
|
|
156
|
+
sweep.entries
|
|
157
|
+
.map((entry) => entry.evaluation.nextTriggerAt)
|
|
158
|
+
.filter((value): value is Date => {
|
|
159
|
+
if (!value) return false
|
|
160
|
+
return value.getTime() > now.getTime()
|
|
161
|
+
})
|
|
162
|
+
.sort((a, b) => a.getTime() - b.getTime())[0] ?? null
|
|
163
|
+
|
|
164
|
+
if (nextTriggerAt) {
|
|
165
|
+
await this.enqueueDeadlineCheck(nextTriggerAt)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async collectDeadlineSweep(
|
|
170
|
+
now: Date,
|
|
171
|
+
): Promise<{
|
|
172
|
+
entries: Array<{ nodeRun: PlanNodeRunRecord; nodeSpec: PlanNodeSpecRecord; evaluation: DeadlineEvaluationResult }>
|
|
173
|
+
}> {
|
|
174
|
+
const activeNodeRuns = await databaseService.queryMany(
|
|
175
|
+
{
|
|
176
|
+
query: `SELECT * FROM ${TABLES.PLAN_NODE_RUN} WHERE status IN $statuses`,
|
|
177
|
+
bindings: { statuses: ['running', 'awaiting-human'] },
|
|
178
|
+
},
|
|
179
|
+
PlanNodeRunSchema,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const entries = []
|
|
183
|
+
|
|
184
|
+
for (const nodeRun of activeNodeRuns) {
|
|
185
|
+
const nodeSpec = await this.loadNodeSpec(nodeRun)
|
|
186
|
+
if (!nodeSpec?.deadline) continue
|
|
187
|
+
|
|
188
|
+
const evaluation = this.evaluateDeadline({
|
|
189
|
+
deadline: nodeSpec.deadline,
|
|
190
|
+
nodeStartedAt: new Date(nodeRun.startedAt ?? nodeRun.createdAt),
|
|
191
|
+
now,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
entries.push({ nodeRun, nodeSpec, evaluation })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { entries }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private resolveDeadlineTime(deadline: DeadlineSpec, nodeStartedAt: Date): Date | null {
|
|
201
|
+
return (
|
|
202
|
+
(deadline.dueAt ? new Date(deadline.dueAt) : null) ??
|
|
203
|
+
(deadline.durationMs !== undefined ? new Date(nodeStartedAt.getTime() + deadline.durationMs) : null)
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async loadNodeSpec(nodeRun: PlanNodeRunRecord): Promise<PlanNodeSpecRecord | null> {
|
|
208
|
+
return databaseService.findOne(
|
|
209
|
+
TABLES.PLAN_NODE_SPEC,
|
|
210
|
+
{ planSpecId: ensureRecordId(nodeRun.planSpecId, TABLES.PLAN_SPEC), nodeId: nodeRun.nodeId },
|
|
211
|
+
PlanNodeSpecRecordSchema,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async applyDeadlineMissAction(params: {
|
|
216
|
+
runId: RecordIdInput
|
|
217
|
+
nodeId: string
|
|
218
|
+
workstreamId: string
|
|
219
|
+
organizationId: string
|
|
220
|
+
action: DeadlineAction
|
|
221
|
+
emittedBy: string
|
|
222
|
+
}): Promise<void> {
|
|
223
|
+
const notificationService = getNotificationService()
|
|
224
|
+
const runIdStr = recordIdToString(params.runId, TABLES.PLAN_RUN)
|
|
225
|
+
|
|
226
|
+
switch (params.action) {
|
|
227
|
+
case 'notify':
|
|
228
|
+
await notificationService.notify({
|
|
229
|
+
organizationId: params.organizationId,
|
|
230
|
+
workstreamId: params.workstreamId,
|
|
231
|
+
runId: runIdStr,
|
|
232
|
+
nodeId: params.nodeId,
|
|
233
|
+
severity: 'warning',
|
|
234
|
+
title: 'Deadline missed',
|
|
235
|
+
body: `Node "${params.nodeId}" has missed its deadline.`,
|
|
236
|
+
dedupeKey: `plan-deadline:${runIdStr}:${params.nodeId}:missed:notify`,
|
|
237
|
+
})
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
case 'escalate':
|
|
241
|
+
await notificationService.escalate({
|
|
242
|
+
organizationId: params.organizationId,
|
|
243
|
+
workstreamId: params.workstreamId,
|
|
244
|
+
runId: runIdStr,
|
|
245
|
+
nodeId: params.nodeId,
|
|
246
|
+
severity: 'urgent',
|
|
247
|
+
title: 'Deadline escalation',
|
|
248
|
+
body: `Node "${params.nodeId}" has missed its deadline and requires escalation.`,
|
|
249
|
+
dedupeKey: `plan-deadline:${runIdStr}:${params.nodeId}:missed:escalate`,
|
|
250
|
+
})
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
case 'block': {
|
|
254
|
+
const { planExecutorService } = await import('./plan-executor.service')
|
|
255
|
+
await planExecutorService.blockNodeOnDispatchFailure({
|
|
256
|
+
workstreamId: params.workstreamId,
|
|
257
|
+
runId: runIdStr,
|
|
258
|
+
nodeId: params.nodeId,
|
|
259
|
+
emittedBy: params.emittedBy,
|
|
260
|
+
message: 'Deadline missed — node blocked',
|
|
261
|
+
failureClass: 'timeout_exceeded',
|
|
262
|
+
})
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'fail': {
|
|
267
|
+
const { planExecutorService } = await import('./plan-executor.service')
|
|
268
|
+
await planExecutorService.blockNodeOnDispatchFailure({
|
|
269
|
+
workstreamId: params.workstreamId,
|
|
270
|
+
runId: runIdStr,
|
|
271
|
+
nodeId: params.nodeId,
|
|
272
|
+
emittedBy: params.emittedBy,
|
|
273
|
+
message: 'Deadline missed — node failed',
|
|
274
|
+
failureClass: 'timeout_exceeded',
|
|
275
|
+
})
|
|
276
|
+
break
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async enqueueDeadlineCheck(scheduledFor: Date): Promise<void> {
|
|
282
|
+
const { enqueueDeadlineCheck } = await import('../queues/plan-scheduler.queue')
|
|
283
|
+
await enqueueDeadlineCheck(scheduledFor)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const planDeadlineService = new PlanDeadlineService()
|