add-coder 0.3.26 → 0.3.27
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/README.md +36 -10
- package/dist/index.js +481 -209
- package/package.json +14 -4
- package/templates/.add-coder-src-hash.json +129 -106
- package/templates/adapters/claude/hooks/doc-format-guard.sh +112 -31
- package/templates/adapters/claude/hooks/lib/common.sh +46 -87
- package/templates/adapters/claude/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/claude/hooks/lib/notify.sh +6 -4
- package/templates/adapters/claude/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/claude/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/claude/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/claude/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/claude/hooks/stop-check.sh +9 -0
- package/templates/adapters/codex/config.toml.example +12 -0
- package/templates/adapters/codex/hooks/doc-format-guard.sh +121 -39
- package/templates/adapters/codex/hooks/lib/common.sh +47 -99
- package/templates/adapters/codex/hooks/lib/context-inject.sh +4 -4
- package/templates/adapters/codex/hooks/lib/notify.sh +6 -4
- package/templates/adapters/codex/hooks/lib/preload-templates.sh +27 -6
- package/templates/adapters/codex/hooks/lib/state-detect.sh +5 -114
- package/templates/adapters/codex/hooks/lib/vocabulary.sh +3 -8
- package/templates/adapters/codex/hooks/notification.sh +3 -18
- package/templates/adapters/codex/hooks/post-tool-use.sh +24 -119
- package/templates/adapters/codex/hooks/pre-tool-use.sh +97 -184
- package/templates/adapters/codex/hooks/prompt-submit.sh +8 -3
- package/templates/adapters/codex/hooks/session-start.sh +19 -9
- package/templates/adapters/codex/hooks/stop-check.sh +44 -18
- package/templates/adapters/codex/hooks.json +67 -1
- package/templates/adapters/qoder/hooks/doc-format-guard.sh +119 -38
- package/templates/adapters/qoder/hooks/lib/common.sh +49 -87
- package/templates/adapters/qoder/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/qoder/hooks/lib/notify.sh +6 -4
- package/templates/adapters/qoder/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/qoder/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/qoder/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/qoder/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/qoder/hooks/session-start.sh +1 -1
- package/templates/adapters/qoder/hooks/stop-check.sh +11 -0
- package/templates/adapters/trae/hooks/doc-format-guard.sh +120 -38
- package/templates/adapters/trae/hooks/lib/common.sh +46 -87
- package/templates/adapters/trae/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/trae/hooks/lib/notify.sh +5 -3
- package/templates/adapters/trae/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/trae/hooks/lib/state-detect.sh +8 -115
- package/templates/adapters/trae/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/trae/hooks/pre-tool-use.sh +14 -8
- package/templates/adapters/trae/hooks/stop-check.sh +9 -0
- package/templates/adapters/vscode/hooks/doc-format-guard.sh +112 -31
- package/templates/adapters/vscode/hooks/lib/common.sh +46 -87
- package/templates/adapters/vscode/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/vscode/hooks/lib/notify.sh +6 -4
- package/templates/adapters/vscode/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/vscode/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/vscode/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/vscode/hooks/notification.sh +3 -5
- package/templates/adapters/vscode/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/vscode/hooks/stop-check.sh +9 -0
- package/templates/core/docs/ADD-governance-claude-code.md +1 -1
- package/templates/core/docs/ADD-governance-codex.md +1 -1
- package/templates/core/docs/ADD-governance-qoder-cn.md +1 -1
- package/templates/core/docs/ADD-governance-trae.md +1 -1
- package/templates/core/docs/ADD-governance-vscode-copilot.md +1 -1
- package/templates/core/hooks/doc-format-guard.sh +120 -38
- package/templates/core/hooks/lib/common.sh +46 -87
- package/templates/core/hooks/lib/context-inject.sh +2 -2
- package/templates/core/hooks/lib/notify.sh +5 -3
- package/templates/core/hooks/lib/preload-templates.sh +27 -2
- package/templates/core/hooks/lib/state-detect.sh +8 -115
- package/templates/core/hooks/lib/vocabulary.sh +13 -7
- package/templates/core/hooks/pre-tool-use.sh +14 -8
- package/templates/core/hooks/stop-check.sh +9 -0
- package/templates/core/prisma/add.prisma +140 -82
- package/templates/core/rules/project_rules.md +27 -24
- package/templates/core/scripts/db-ensure.sh +8 -0
- package/templates/core/scripts/gen-plan-index.sh +4 -15
- package/templates/core/scripts/mcp-server/notifications/hitl.ts +9 -1
- package/templates/core/scripts/mcp-server/notifications/hook.ts +25 -9
- package/templates/core/scripts/mcp-server/resources/hitl-approval-widget.ts +34 -0
- package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +14 -2
- package/templates/core/scripts/mcp-server/resources/index.ts +2 -0
- package/templates/core/scripts/mcp-server/sampling/review.ts +25 -10
- package/templates/core/scripts/mcp-server/shared/db-types.ts +33 -8
- package/templates/core/scripts/mcp-server/shared/dps-scoring.strategy.ts +1 -1
- package/templates/core/scripts/mcp-server/shared/env.ts +28 -8
- package/templates/core/scripts/mcp-server/shared/fs.ts +3 -0
- package/templates/core/scripts/mcp-server/shared/hitl-interaction.strategy.ts +2 -3
- package/templates/core/scripts/mcp-server/shared/hitl-lifecycle-mutation.ts +129 -0
- package/templates/core/scripts/mcp-server/shared/hitl-proposal-mutation.ts +98 -0
- package/templates/core/scripts/mcp-server/shared/hitl-ui.ts +3 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-events.ts +62 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-mutation.ts +78 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-subscriber.ts +145 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle.ts +153 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-events.ts +56 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-mutation.ts +143 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-store.ts +47 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-subscriber.ts +99 -0
- package/templates/core/scripts/mcp-server/shared/plan-status-store.ts +37 -0
- package/templates/core/scripts/mcp-server/shared/plan-tracking-mutation.ts +71 -0
- package/templates/core/scripts/mcp-server/shared/prisma.ts +12 -6
- package/templates/core/scripts/mcp-server/shared/project-root-strategy.ts +1 -2
- package/templates/core/scripts/mcp-server/shared/quant-recheck.strategy.ts +16 -0
- package/templates/core/scripts/mcp-server/shared/redact.ts +28 -0
- package/templates/core/scripts/mcp-server/shared/response.ts +3 -2
- package/templates/core/scripts/mcp-server/shared/runtime-context.ts +77 -0
- package/templates/core/scripts/mcp-server/shared/schema-topology.ts +159 -0
- package/templates/core/scripts/mcp-server/tools/audit.ts +49 -7
- package/templates/core/scripts/mcp-server/tools/context.ts +117 -121
- package/templates/core/scripts/mcp-server/tools/contract.ts +34 -9
- package/templates/core/scripts/mcp-server/tools/gateway/check_add_route_status.ts +50 -3
- package/templates/core/scripts/mcp-server/tools/gateway/check_doc_similarity.ts +218 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +22 -7
- package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +75 -4
- package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +19 -6
- package/templates/core/scripts/mcp-server/tools/gateway/index.ts +2 -0
- package/templates/core/scripts/mcp-server/tools/hitl.ts +160 -75
- package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +9 -2
- package/templates/core/scripts/mcp-server/tools/index.ts +69 -3
- package/templates/core/scripts/mcp-server/tools/plan.ts +140 -15
- package/templates/core/scripts/mcp-server/tools/review.ts +44 -15
- package/templates/core/scripts/mcp-server.ts +60 -1
- package/templates/core/scripts/plan-status-bridge.ts +26 -0
- package/templates/core/skills/add-paradigm/SKILL.md +2 -2
- package/templates/core/templates/add-route-template.schema.json +24 -56
- package/templates/core/templates/collab-contract-template.md +1 -1
- package/templates/core/templates/handoff-multi-round-template.schema.json +48 -0
- package/templates/core/templates/handoff-single-round-template.schema.json +92 -0
- package/templates/core/templates/handoff-single-round.schema.json +1 -1
- package/templates/core/templates/hitl-approval-widget.html +222 -37
- package/templates/core/templates/spec-template.schema.json +9 -46
- package/templates/core/templates/standard-plan-template.schema.json +1 -1
- package/templates/core/templates/tasks-template.schema.json +13 -54
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
2
|
+
import type { PlanStatusResolution } from "./plan-lifecycle.js"
|
|
3
|
+
import {
|
|
4
|
+
isLifecycleEventForContext,
|
|
5
|
+
parsePlanLifecycleChangedEnvelope,
|
|
6
|
+
PLAN_LIFECYCLE_CHANNEL,
|
|
7
|
+
type PlanLifecycleChangedEnvelope,
|
|
8
|
+
} from "./plan-lifecycle-events.js"
|
|
9
|
+
|
|
10
|
+
export interface PgNotification {
|
|
11
|
+
channel: string
|
|
12
|
+
payload?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LifecycleListenClient {
|
|
16
|
+
connect(): Promise<unknown>
|
|
17
|
+
query(sql: string): Promise<unknown>
|
|
18
|
+
end(): Promise<void>
|
|
19
|
+
on(event: "notification", listener: (message: PgNotification) => void): this
|
|
20
|
+
on(event: "error", listener: (error: Error) => void): this
|
|
21
|
+
on(event: "end", listener: () => void): this
|
|
22
|
+
removeAllListeners(): this
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LifecycleListenClientFactory = () => LifecycleListenClient | Promise<LifecycleListenClient>
|
|
26
|
+
|
|
27
|
+
export interface PlanLifecycleSubscriberOptions {
|
|
28
|
+
context: RuntimeContextKey
|
|
29
|
+
clientFactory: LifecycleListenClientFactory
|
|
30
|
+
resolveStatus(input: {
|
|
31
|
+
context: RuntimeContextKey
|
|
32
|
+
planId?: string
|
|
33
|
+
reason: "initial" | "notification" | "reconnect"
|
|
34
|
+
}): Promise<PlanStatusResolution>
|
|
35
|
+
onSnapshot(snapshot: PlanStatusResolution, envelope?: PlanLifecycleChangedEnvelope): void | Promise<void>
|
|
36
|
+
onError?(error: Error): void
|
|
37
|
+
reconnectDelayMs?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PlanLifecycleSubscriber {
|
|
41
|
+
readonly #options: PlanLifecycleSubscriberOptions
|
|
42
|
+
#client: LifecycleListenClient | null = null
|
|
43
|
+
#stopping = false
|
|
44
|
+
#startedOnce = false
|
|
45
|
+
#reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
46
|
+
#refreshQueue: Promise<void> = Promise.resolve()
|
|
47
|
+
|
|
48
|
+
constructor(options: PlanLifecycleSubscriberOptions) {
|
|
49
|
+
this.#options = options
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async start(): Promise<void> {
|
|
53
|
+
if (this.#client) return
|
|
54
|
+
this.#stopping = false
|
|
55
|
+
try {
|
|
56
|
+
await this.#connectAndHydrate("initial")
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.#scheduleReconnect()
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async stop(): Promise<void> {
|
|
64
|
+
this.#stopping = true
|
|
65
|
+
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
|
|
66
|
+
this.#reconnectTimer = null
|
|
67
|
+
const client = this.#client
|
|
68
|
+
this.#client = null
|
|
69
|
+
if (!client) return
|
|
70
|
+
client.removeAllListeners()
|
|
71
|
+
try { await client.query(`UNLISTEN ${PLAN_LIFECYCLE_CHANNEL}`) } catch { /* connection may already be gone */ }
|
|
72
|
+
await client.end().catch(() => undefined)
|
|
73
|
+
await this.#refreshQueue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #connectAndHydrate(reason: "initial" | "reconnect"): Promise<void> {
|
|
77
|
+
const client = await this.#options.clientFactory()
|
|
78
|
+
this.#client = client
|
|
79
|
+
client.on("notification", (message) => this.#handleNotification(message))
|
|
80
|
+
client.on("error", (error) => this.#handleDisconnect(client, error))
|
|
81
|
+
client.on("end", () => this.#handleDisconnect(client, new Error("PostgreSQL LISTEN session ended")))
|
|
82
|
+
try {
|
|
83
|
+
await client.connect()
|
|
84
|
+
await client.query(`LISTEN ${PLAN_LIFECYCLE_CHANNEL}`)
|
|
85
|
+
await this.#refresh(undefined, reason)
|
|
86
|
+
this.#startedOnce = true
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (this.#client === client) this.#client = null
|
|
89
|
+
client.removeAllListeners()
|
|
90
|
+
await client.end().catch(() => undefined)
|
|
91
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
92
|
+
this.#options.onError?.(normalized)
|
|
93
|
+
throw normalized
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#handleNotification(message: PgNotification): void {
|
|
98
|
+
if (this.#stopping || message.channel !== PLAN_LIFECYCLE_CHANNEL) return
|
|
99
|
+
const envelope = parsePlanLifecycleChangedEnvelope(message.payload)
|
|
100
|
+
if (!envelope || !isLifecycleEventForContext(envelope, this.#options.context)) return
|
|
101
|
+
this.#refreshQueue = this.#refreshQueue
|
|
102
|
+
.then(() => this.#refresh(envelope, "notification"))
|
|
103
|
+
.catch((error: unknown) => this.#options.onError?.(error instanceof Error ? error : new Error(String(error))))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#handleDisconnect(client: LifecycleListenClient, error: Error): void {
|
|
107
|
+
if (this.#stopping || this.#client !== client) return
|
|
108
|
+
this.#client = null
|
|
109
|
+
client.removeAllListeners()
|
|
110
|
+
this.#options.onError?.(error)
|
|
111
|
+
if (this.#reconnectTimer) return
|
|
112
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
113
|
+
this.#reconnectTimer = null
|
|
114
|
+
if (this.#stopping) return
|
|
115
|
+
this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial")
|
|
116
|
+
.catch(() => this.#scheduleReconnect())
|
|
117
|
+
}, this.#options.reconnectDelayMs ?? 1000)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#scheduleReconnect(): void {
|
|
121
|
+
if (this.#stopping || this.#reconnectTimer) return
|
|
122
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
123
|
+
this.#reconnectTimer = null
|
|
124
|
+
this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial")
|
|
125
|
+
.catch(() => this.#scheduleReconnect())
|
|
126
|
+
}, this.#options.reconnectDelayMs ?? 1000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #refresh(
|
|
130
|
+
envelope: PlanLifecycleChangedEnvelope | undefined,
|
|
131
|
+
reason: "initial" | "notification" | "reconnect",
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const snapshot = await this.#options.resolveStatus({
|
|
134
|
+
context: this.#options.context,
|
|
135
|
+
planId: envelope?.planId,
|
|
136
|
+
reason,
|
|
137
|
+
})
|
|
138
|
+
await this.#options.onSnapshot(snapshot, envelope)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function createPgLifecycleListenClient(databaseUrl: string): Promise<LifecycleListenClient> {
|
|
143
|
+
const { Client } = await import("pg")
|
|
144
|
+
return new Client({ connectionString: databaseUrl })
|
|
145
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
2
|
+
|
|
3
|
+
export const PLAN_LIFECYCLE_STATUSES = [
|
|
4
|
+
"DRAFT",
|
|
5
|
+
"ACTIVE",
|
|
6
|
+
"BLOCKED",
|
|
7
|
+
"REJECTED",
|
|
8
|
+
"CLOSED",
|
|
9
|
+
"ABANDONED",
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export type PlanLifecycleStatus = (typeof PLAN_LIFECYCLE_STATUSES)[number]
|
|
13
|
+
export type PlanApprovalStatus = "DRAFT" | "SUBMITTED" | "TONGYI" | "BOHUI"
|
|
14
|
+
|
|
15
|
+
export interface ScopedPlanStatusRow {
|
|
16
|
+
id: string
|
|
17
|
+
projectKey: string
|
|
18
|
+
adapterKey: string
|
|
19
|
+
planName: string
|
|
20
|
+
lifecycle: PlanLifecycleStatus
|
|
21
|
+
revision: number
|
|
22
|
+
doneTasks: number
|
|
23
|
+
totalTasks: number
|
|
24
|
+
checklistTDone: number
|
|
25
|
+
checklistT: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlanStatusStore {
|
|
29
|
+
findPlan(input: {
|
|
30
|
+
context: RuntimeContextKey
|
|
31
|
+
planName?: string
|
|
32
|
+
planId?: string
|
|
33
|
+
activeOnly?: boolean
|
|
34
|
+
}): Promise<ScopedPlanStatusRow | null>
|
|
35
|
+
findLatestPlanApproval(input: {
|
|
36
|
+
context: RuntimeContextKey
|
|
37
|
+
planName: string
|
|
38
|
+
}): Promise<PlanApprovalStatus | null>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PlanStatusSnapshot {
|
|
42
|
+
availability: "READY"
|
|
43
|
+
source: "database"
|
|
44
|
+
context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
|
|
45
|
+
planName: string
|
|
46
|
+
planId: string
|
|
47
|
+
lifecycle: PlanLifecycleStatus
|
|
48
|
+
revision: number
|
|
49
|
+
isActive: boolean
|
|
50
|
+
approvalStatus: PlanApprovalStatus | null
|
|
51
|
+
progress: {
|
|
52
|
+
doneTasks: number
|
|
53
|
+
totalTasks: number
|
|
54
|
+
checklistTDone: number
|
|
55
|
+
checklistT: number
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface NoActivePlanSnapshot {
|
|
60
|
+
availability: "READY"
|
|
61
|
+
source: "database"
|
|
62
|
+
context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
|
|
63
|
+
planName: null
|
|
64
|
+
lifecycle: null
|
|
65
|
+
isActive: false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PlanStatusUnavailable {
|
|
69
|
+
availability: "STATUS_UNAVAILABLE"
|
|
70
|
+
source: "database"
|
|
71
|
+
context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
|
|
72
|
+
reason: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type PlanStatusResolution = PlanStatusSnapshot | NoActivePlanSnapshot | PlanStatusUnavailable
|
|
76
|
+
|
|
77
|
+
const ALLOWED_TRANSITIONS: Readonly<Record<PlanLifecycleStatus, readonly PlanLifecycleStatus[]>> = {
|
|
78
|
+
DRAFT: ["ACTIVE", "REJECTED", "ABANDONED"],
|
|
79
|
+
ACTIVE: ["BLOCKED", "CLOSED", "ABANDONED"],
|
|
80
|
+
BLOCKED: ["ACTIVE", "CLOSED", "ABANDONED"],
|
|
81
|
+
REJECTED: ["DRAFT", "ACTIVE", "ABANDONED"],
|
|
82
|
+
CLOSED: [],
|
|
83
|
+
ABANDONED: [],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isActiveLifecycle(lifecycle: PlanLifecycleStatus): boolean {
|
|
87
|
+
return lifecycle === "ACTIVE" || lifecycle === "BLOCKED"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function assertLifecycleTransition(from: PlanLifecycleStatus, to: PlanLifecycleStatus): void {
|
|
91
|
+
if (from === to) return
|
|
92
|
+
if (!ALLOWED_TRANSITIONS[from].includes(to)) {
|
|
93
|
+
throw new Error(`非法 Plan lifecycle 迁移: ${from} → ${to}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function snapshotContext(context: RuntimeContextKey) {
|
|
98
|
+
return { projectKey: context.projectKey, adapterKey: context.adapterKey, contextId: context.contextId }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function resolvePlanStatus(
|
|
102
|
+
store: PlanStatusStore,
|
|
103
|
+
context: RuntimeContextKey,
|
|
104
|
+
selector?: string | { planName?: string; planId?: string; activeOnly?: boolean },
|
|
105
|
+
): Promise<PlanStatusResolution> {
|
|
106
|
+
try {
|
|
107
|
+
const selection = typeof selector === "string" ? { planName: selector } : (selector ?? {})
|
|
108
|
+
const plan = await store.findPlan({
|
|
109
|
+
context,
|
|
110
|
+
planName: selection.planName,
|
|
111
|
+
planId: selection.planId,
|
|
112
|
+
activeOnly: selection.activeOnly ?? (selection.planName === undefined && selection.planId === undefined),
|
|
113
|
+
})
|
|
114
|
+
if (!plan) {
|
|
115
|
+
return {
|
|
116
|
+
availability: "READY",
|
|
117
|
+
source: "database",
|
|
118
|
+
context: snapshotContext(context),
|
|
119
|
+
planName: null,
|
|
120
|
+
lifecycle: null,
|
|
121
|
+
isActive: false,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (plan.projectKey !== context.projectKey || plan.adapterKey !== context.adapterKey) {
|
|
125
|
+
throw new Error("PlanStatusStore 返回了 scope 外记录")
|
|
126
|
+
}
|
|
127
|
+
const approvalStatus = await store.findLatestPlanApproval({ context, planName: plan.planName })
|
|
128
|
+
return {
|
|
129
|
+
availability: "READY",
|
|
130
|
+
source: "database",
|
|
131
|
+
context: snapshotContext(context),
|
|
132
|
+
planName: plan.planName,
|
|
133
|
+
planId: plan.id,
|
|
134
|
+
lifecycle: plan.lifecycle,
|
|
135
|
+
revision: plan.revision,
|
|
136
|
+
isActive: isActiveLifecycle(plan.lifecycle),
|
|
137
|
+
approvalStatus,
|
|
138
|
+
progress: {
|
|
139
|
+
doneTasks: plan.doneTasks,
|
|
140
|
+
totalTasks: plan.totalTasks,
|
|
141
|
+
checklistTDone: plan.checklistTDone,
|
|
142
|
+
checklistT: plan.checklistT,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
availability: "STATUS_UNAVAILABLE",
|
|
148
|
+
source: "database",
|
|
149
|
+
context: snapshotContext(context),
|
|
150
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "crypto"
|
|
2
|
+
import * as z from "zod/v4"
|
|
3
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
4
|
+
import type { PrismaNotifyTransaction } from "./plan-lifecycle-events.js"
|
|
5
|
+
|
|
6
|
+
export const PLAN_ROUND_CHANNEL = "add_plan_round_changed_v1"
|
|
7
|
+
|
|
8
|
+
export const PlanRoundChangedEnvelopeSchema = z.object({
|
|
9
|
+
schemaVersion: z.literal(1),
|
|
10
|
+
eventId: z.string().min(1),
|
|
11
|
+
projectKey: z.string().min(1),
|
|
12
|
+
adapterKey: z.string().min(1),
|
|
13
|
+
planId: z.string().min(1),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export type PlanRoundChangedEnvelope = z.infer<typeof PlanRoundChangedEnvelopeSchema>
|
|
17
|
+
|
|
18
|
+
export function createPlanRoundChangedEnvelope(input: {
|
|
19
|
+
context: RuntimeContextKey
|
|
20
|
+
planId: string
|
|
21
|
+
eventId?: string
|
|
22
|
+
}): PlanRoundChangedEnvelope {
|
|
23
|
+
return {
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
eventId: input.eventId ?? randomUUID(),
|
|
26
|
+
projectKey: input.context.projectKey,
|
|
27
|
+
adapterKey: input.context.adapterKey,
|
|
28
|
+
planId: input.planId,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parsePlanRoundChangedEnvelope(payload: string | undefined): PlanRoundChangedEnvelope | null {
|
|
33
|
+
if (!payload) return null
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(payload)
|
|
36
|
+
const result = PlanRoundChangedEnvelopeSchema.safeParse(parsed)
|
|
37
|
+
return result.success ? result.data : null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isPlanRoundEventForContext(
|
|
44
|
+
envelope: PlanRoundChangedEnvelope,
|
|
45
|
+
context: RuntimeContextKey,
|
|
46
|
+
): boolean {
|
|
47
|
+
return envelope.projectKey === context.projectKey && envelope.adapterKey === context.adapterKey
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function publishPlanRoundChanged(
|
|
51
|
+
tx: PrismaNotifyTransaction,
|
|
52
|
+
envelope: PlanRoundChangedEnvelope,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const payload = JSON.stringify(envelope)
|
|
55
|
+
await tx.$executeRaw`SELECT pg_notify(${PLAN_ROUND_CHANNEL}, ${payload})`
|
|
56
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "crypto"
|
|
2
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
3
|
+
import {
|
|
4
|
+
createPlanRoundChangedEnvelope,
|
|
5
|
+
publishPlanRoundChanged,
|
|
6
|
+
} from "./plan-round-events.js"
|
|
7
|
+
import type { PrismaNotifyTransaction } from "./plan-lifecycle-events.js"
|
|
8
|
+
|
|
9
|
+
export interface PlanRoundPlanRow {
|
|
10
|
+
id: string
|
|
11
|
+
projectKey: string
|
|
12
|
+
adapterKey: string
|
|
13
|
+
planName: string
|
|
14
|
+
lifecycle: string
|
|
15
|
+
revision: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlanRoundOperationRow {
|
|
19
|
+
id: string
|
|
20
|
+
projectKey: string
|
|
21
|
+
producerAdapterKey: string
|
|
22
|
+
contextId: string
|
|
23
|
+
toolName: string
|
|
24
|
+
operationKey: string
|
|
25
|
+
planKeyword: string
|
|
26
|
+
action: string
|
|
27
|
+
targetType: string
|
|
28
|
+
targetId: string
|
|
29
|
+
beforeState: unknown
|
|
30
|
+
afterState: unknown
|
|
31
|
+
reason: string | null
|
|
32
|
+
createdAt: Date
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PlanRoundTransaction extends PrismaNotifyTransaction {
|
|
36
|
+
planRecord: {
|
|
37
|
+
findFirst(args: { where: Record<string, unknown> }): Promise<PlanRoundPlanRow | null>
|
|
38
|
+
}
|
|
39
|
+
addUser: {
|
|
40
|
+
findUnique(args: { where: Record<string, unknown>; select: Record<string, unknown> }): Promise<{ id: string } | null>
|
|
41
|
+
create(args: { data: Record<string, unknown> }): Promise<{ id: string }>
|
|
42
|
+
}
|
|
43
|
+
devOperation: {
|
|
44
|
+
upsert(args: { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<PlanRoundOperationRow>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PlanRoundDatabase {
|
|
49
|
+
$transaction<T>(work: (tx: PlanRoundTransaction) => Promise<T>): Promise<T>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClosePlanRoundInput {
|
|
53
|
+
context: RuntimeContextKey
|
|
54
|
+
planName: string
|
|
55
|
+
round: number
|
|
56
|
+
beforeState: Record<string, unknown> | unknown[]
|
|
57
|
+
afterState: Record<string, unknown> | unknown[]
|
|
58
|
+
reason?: string
|
|
59
|
+
operationKey?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ClosePlanRoundResult {
|
|
63
|
+
plan: PlanRoundPlanRow
|
|
64
|
+
operation: PlanRoundOperationRow
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function defaultPlanRoundOperationKey(input: {
|
|
68
|
+
context: RuntimeContextKey
|
|
69
|
+
planId: string
|
|
70
|
+
round: number
|
|
71
|
+
}): string {
|
|
72
|
+
return createHash("sha256")
|
|
73
|
+
.update(`${input.context.contextId}:${input.planId}:ROUND_CLOSED:${input.round}`)
|
|
74
|
+
.digest("hex")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function closePlanRoundAndPublish(
|
|
78
|
+
database: PlanRoundDatabase,
|
|
79
|
+
input: ClosePlanRoundInput,
|
|
80
|
+
): Promise<ClosePlanRoundResult> {
|
|
81
|
+
if (!Number.isInteger(input.round) || input.round < 1) throw new Error("round 必须是大于 0 的整数")
|
|
82
|
+
|
|
83
|
+
return database.$transaction(async (tx) => {
|
|
84
|
+
const plan = await tx.planRecord.findFirst({
|
|
85
|
+
where: {
|
|
86
|
+
projectKey: input.context.projectKey,
|
|
87
|
+
adapterKey: input.context.adapterKey,
|
|
88
|
+
planName: input.planName,
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
if (!plan) throw new Error(`当前 RuntimeContextKey 下不存在 Plan: ${input.planName}`)
|
|
92
|
+
if (plan.projectKey !== input.context.projectKey || plan.adapterKey !== input.context.adapterKey) {
|
|
93
|
+
throw new Error("拒绝关闭 scope 外 PlanRound")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let user = await tx.addUser.findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
|
|
97
|
+
if (!user) {
|
|
98
|
+
user = await tx.addUser.create({
|
|
99
|
+
data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" },
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
const baseOperationKey = defaultPlanRoundOperationKey({
|
|
103
|
+
context: input.context,
|
|
104
|
+
planId: plan.id,
|
|
105
|
+
round: input.round,
|
|
106
|
+
})
|
|
107
|
+
const operationKey = input.operationKey?.trim()
|
|
108
|
+
? createHash("sha256").update(`${baseOperationKey}:${input.operationKey.trim()}`).digest("hex")
|
|
109
|
+
: baseOperationKey
|
|
110
|
+
const operation = await tx.devOperation.upsert({
|
|
111
|
+
where: {
|
|
112
|
+
projectKey_producerAdapterKey_toolName_operationKey: {
|
|
113
|
+
projectKey: input.context.projectKey,
|
|
114
|
+
producerAdapterKey: input.context.adapterKey,
|
|
115
|
+
toolName: "plan_round_close",
|
|
116
|
+
operationKey,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
create: {
|
|
120
|
+
userId: user.id,
|
|
121
|
+
projectKey: input.context.projectKey,
|
|
122
|
+
producerAdapterKey: input.context.adapterKey,
|
|
123
|
+
contextId: input.context.contextId,
|
|
124
|
+
toolName: "plan_round_close",
|
|
125
|
+
operationKey,
|
|
126
|
+
planKeyword: input.planName,
|
|
127
|
+
action: "ROUND_CLOSED",
|
|
128
|
+
targetType: "PLAN_ROUND",
|
|
129
|
+
targetId: `${input.planName}::round${input.round}`,
|
|
130
|
+
beforeState: input.beforeState,
|
|
131
|
+
afterState: input.afterState,
|
|
132
|
+
reason: input.reason ?? null,
|
|
133
|
+
},
|
|
134
|
+
update: {},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await publishPlanRoundChanged(tx, createPlanRoundChangedEnvelope({
|
|
138
|
+
context: input.context,
|
|
139
|
+
planId: plan.id,
|
|
140
|
+
}))
|
|
141
|
+
return { plan, operation }
|
|
142
|
+
})
|
|
143
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
2
|
+
import type { PlanRoundOperationRow } from "./plan-round-mutation.js"
|
|
3
|
+
|
|
4
|
+
export interface PlanRoundReadClient {
|
|
5
|
+
planRecord: {
|
|
6
|
+
findFirst(args: { where: Record<string, unknown>; orderBy: Record<string, unknown> }): Promise<{ id: string; planName: string } | null>
|
|
7
|
+
}
|
|
8
|
+
devOperation: {
|
|
9
|
+
findMany(args: { where: Record<string, unknown>; orderBy: Record<string, unknown> }): Promise<PlanRoundOperationRow[]>
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PlanRoundSnapshot {
|
|
14
|
+
context: RuntimeContextKey
|
|
15
|
+
planId: string | null
|
|
16
|
+
planName: string | null
|
|
17
|
+
rounds: PlanRoundOperationRow[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function queryPlanRounds(
|
|
21
|
+
client: PlanRoundReadClient,
|
|
22
|
+
input: { context: RuntimeContextKey; planName?: string; planId?: string; round?: number },
|
|
23
|
+
): Promise<PlanRoundSnapshot> {
|
|
24
|
+
const explicitPlan = Boolean(input.planName || input.planId)
|
|
25
|
+
const plan = await client.planRecord.findFirst({
|
|
26
|
+
where: {
|
|
27
|
+
projectKey: input.context.projectKey,
|
|
28
|
+
adapterKey: input.context.adapterKey,
|
|
29
|
+
...(input.planName ? { planName: input.planName } : {}),
|
|
30
|
+
...(input.planId ? { id: input.planId } : {}),
|
|
31
|
+
...(!explicitPlan ? { lifecycle: { in: ["ACTIVE", "BLOCKED"] } } : {}),
|
|
32
|
+
},
|
|
33
|
+
orderBy: { updatedAt: "desc" },
|
|
34
|
+
})
|
|
35
|
+
if (!plan) return { context: input.context, planId: null, planName: null, rounds: [] }
|
|
36
|
+
|
|
37
|
+
const where: Record<string, unknown> = {
|
|
38
|
+
projectKey: input.context.projectKey,
|
|
39
|
+
producerAdapterKey: input.context.adapterKey,
|
|
40
|
+
toolName: "plan_round_close",
|
|
41
|
+
action: "ROUND_CLOSED",
|
|
42
|
+
planKeyword: plan.planName,
|
|
43
|
+
}
|
|
44
|
+
if (input.round !== undefined) where.targetId = `${plan.planName}::round${input.round}`
|
|
45
|
+
const rounds = await client.devOperation.findMany({ where, orderBy: { createdAt: "asc" } })
|
|
46
|
+
return { context: input.context, planId: plan.id, planName: plan.planName, rounds }
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
2
|
+
import type { LifecycleListenClient, LifecycleListenClientFactory, PgNotification } from "./plan-lifecycle-subscriber.js"
|
|
3
|
+
import type { PlanRoundSnapshot } from "./plan-round-store.js"
|
|
4
|
+
import {
|
|
5
|
+
isPlanRoundEventForContext,
|
|
6
|
+
parsePlanRoundChangedEnvelope,
|
|
7
|
+
PLAN_ROUND_CHANNEL,
|
|
8
|
+
type PlanRoundChangedEnvelope,
|
|
9
|
+
} from "./plan-round-events.js"
|
|
10
|
+
|
|
11
|
+
export interface PlanRoundSubscriberOptions {
|
|
12
|
+
context: RuntimeContextKey
|
|
13
|
+
clientFactory: LifecycleListenClientFactory
|
|
14
|
+
queryRounds(input: { context: RuntimeContextKey; planId?: string; reason: "initial" | "notification" | "reconnect" }): Promise<PlanRoundSnapshot>
|
|
15
|
+
onSnapshot(snapshot: PlanRoundSnapshot, envelope?: PlanRoundChangedEnvelope): void | Promise<void>
|
|
16
|
+
onError?(error: Error): void
|
|
17
|
+
reconnectDelayMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PlanRoundSubscriber {
|
|
21
|
+
readonly #options: PlanRoundSubscriberOptions
|
|
22
|
+
#client: LifecycleListenClient | null = null
|
|
23
|
+
#stopping = false
|
|
24
|
+
#startedOnce = false
|
|
25
|
+
#reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
26
|
+
#refreshQueue: Promise<void> = Promise.resolve()
|
|
27
|
+
|
|
28
|
+
constructor(options: PlanRoundSubscriberOptions) { this.#options = options }
|
|
29
|
+
|
|
30
|
+
async start(): Promise<void> {
|
|
31
|
+
if (this.#client) return
|
|
32
|
+
this.#stopping = false
|
|
33
|
+
try { await this.#connectAndHydrate("initial") } catch (error) { this.#scheduleReconnect(); throw error }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async stop(): Promise<void> {
|
|
37
|
+
this.#stopping = true
|
|
38
|
+
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
|
|
39
|
+
this.#reconnectTimer = null
|
|
40
|
+
const client = this.#client
|
|
41
|
+
this.#client = null
|
|
42
|
+
if (!client) return
|
|
43
|
+
client.removeAllListeners()
|
|
44
|
+
try { await client.query(`UNLISTEN ${PLAN_ROUND_CHANNEL}`) } catch { /* connection may already be gone */ }
|
|
45
|
+
await client.end().catch(() => undefined)
|
|
46
|
+
await this.#refreshQueue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async #connectAndHydrate(reason: "initial" | "reconnect"): Promise<void> {
|
|
50
|
+
const client = await this.#options.clientFactory()
|
|
51
|
+
this.#client = client
|
|
52
|
+
client.on("notification", (message: PgNotification) => this.#handleNotification(message))
|
|
53
|
+
client.on("error", (error: Error) => this.#handleDisconnect(client, error))
|
|
54
|
+
client.on("end", () => this.#handleDisconnect(client, new Error("PostgreSQL PlanRound LISTEN session ended")))
|
|
55
|
+
try {
|
|
56
|
+
await client.connect()
|
|
57
|
+
await client.query(`LISTEN ${PLAN_ROUND_CHANNEL}`)
|
|
58
|
+
await this.#refresh(undefined, reason)
|
|
59
|
+
this.#startedOnce = true
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (this.#client === client) this.#client = null
|
|
62
|
+
client.removeAllListeners()
|
|
63
|
+
await client.end().catch(() => undefined)
|
|
64
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
65
|
+
this.#options.onError?.(normalized)
|
|
66
|
+
throw normalized
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#handleNotification(message: PgNotification): void {
|
|
71
|
+
if (this.#stopping || message.channel !== PLAN_ROUND_CHANNEL) return
|
|
72
|
+
const envelope = parsePlanRoundChangedEnvelope(message.payload)
|
|
73
|
+
if (!envelope || !isPlanRoundEventForContext(envelope, this.#options.context)) return
|
|
74
|
+
this.#refreshQueue = this.#refreshQueue
|
|
75
|
+
.then(() => this.#refresh(envelope, "notification"))
|
|
76
|
+
.catch((error: unknown) => this.#options.onError?.(error instanceof Error ? error : new Error(String(error))))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#handleDisconnect(client: LifecycleListenClient, error: Error): void {
|
|
80
|
+
if (this.#stopping || this.#client !== client) return
|
|
81
|
+
this.#client = null
|
|
82
|
+
client.removeAllListeners()
|
|
83
|
+
this.#options.onError?.(error)
|
|
84
|
+
this.#scheduleReconnect()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#scheduleReconnect(): void {
|
|
88
|
+
if (this.#stopping || this.#reconnectTimer) return
|
|
89
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
90
|
+
this.#reconnectTimer = null
|
|
91
|
+
this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial").catch(() => this.#scheduleReconnect())
|
|
92
|
+
}, this.#options.reconnectDelayMs ?? 1000)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async #refresh(envelope: PlanRoundChangedEnvelope | undefined, reason: "initial" | "notification" | "reconnect"): Promise<void> {
|
|
96
|
+
const snapshot = await this.#options.queryRounds({ context: this.#options.context, planId: envelope?.planId, reason })
|
|
97
|
+
await this.#options.onSnapshot(snapshot, envelope)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HitlRow, PlanRow } from "./db-types.js"
|
|
2
|
+
import { HitlRowSchema, PlanRowSchema, validatedDelegate } from "./db-types.js"
|
|
3
|
+
import type { PlanStatusStore } from "./plan-lifecycle.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 数据库是 lifecycle 真相源。所有查询都强制携带 RuntimeContextKey,禁止同名
|
|
7
|
+
* Plan 在 adapter 间串线;LISTEN/NOTIFY 只负责唤醒本 store 重新查询。
|
|
8
|
+
*/
|
|
9
|
+
export function createPrismaPlanStatusStore(client: Record<string, unknown>): PlanStatusStore {
|
|
10
|
+
const plans = validatedDelegate<PlanRow>(client.planRecord, PlanRowSchema, "PlanRecord")
|
|
11
|
+
const hitls = validatedDelegate<HitlRow>(client.hitlRecord, HitlRowSchema, "HitlRecord")
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
async findPlan({ context, planName, planId, activeOnly }) {
|
|
15
|
+
const where: Record<string, unknown> = {
|
|
16
|
+
projectKey: context.projectKey,
|
|
17
|
+
adapterKey: context.adapterKey,
|
|
18
|
+
}
|
|
19
|
+
if (planName) where.planName = planName
|
|
20
|
+
if (planId) where.id = planId
|
|
21
|
+
if (activeOnly) where.lifecycle = { in: ["ACTIVE", "BLOCKED"] }
|
|
22
|
+
return plans.findFirst({ where, orderBy: { updatedAt: "desc" } })
|
|
23
|
+
},
|
|
24
|
+
async findLatestPlanApproval({ context, planName }) {
|
|
25
|
+
const record = await hitls.findFirst({
|
|
26
|
+
where: {
|
|
27
|
+
projectKey: context.projectKey,
|
|
28
|
+
adapterKey: context.adapterKey,
|
|
29
|
+
planName,
|
|
30
|
+
type: "PLAN",
|
|
31
|
+
},
|
|
32
|
+
orderBy: { round: "desc" },
|
|
33
|
+
})
|
|
34
|
+
return record?.status ?? null
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|