@danceiny/gotry 0.0.1-rc.11 → 0.0.1-rc.13
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 +111 -146
- package/cordis.gotry-patch.yml +14 -7
- package/dist/capabilities/flyai.js +135 -30
- package/dist/capabilities/hbcli.js +14 -2
- package/dist/capabilities/session/benchmark.js +252 -0
- package/dist/capabilities/session/transport.js +25 -10
- package/dist/capabilities/session-consent.js +82 -0
- package/dist/capabilities/session-login.js +119 -0
- package/dist/capabilities/session-search.js +7 -4
- package/dist/capabilities/weather.js +72 -17
- package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
- package/dist/scripts/async-collect.js +33 -5
- package/dist/scripts/hbcli-tests.js +25 -4
- package/dist/scripts/ledger-tests.js +134 -4
- package/dist/scripts/memory-value-report.js +379 -0
- package/dist/scripts/product-metrics.js +569 -0
- package/dist/scripts/session-benchmark.js +261 -0
- package/dist/scripts/session-login.js +28 -0
- package/dist/scripts/session-tests.js +262 -23
- package/dist/scripts/smoke.js +112 -14
- package/dist/scripts/weather-tests.js +8 -1
- package/dist/src/index.js +124 -5
- package/dist/src/loop.js +65 -14
- package/dist/src/state-ledger.js +30 -4
- package/package.json +4 -2
- package/ts/capabilities/flyai.ts +177 -22
- package/ts/capabilities/hbcli.ts +16 -4
- package/ts/capabilities/session/benchmark.ts +273 -0
- package/ts/capabilities/session/transport.ts +41 -9
- package/ts/capabilities/session-consent.ts +127 -0
- package/ts/capabilities/session-login.ts +146 -0
- package/ts/capabilities/session-search.ts +13 -5
- package/ts/capabilities/weather.ts +84 -19
- package/ts/scripts/async-collect.ts +48 -7
- package/ts/src/index.ts +107 -11
- package/ts/src/loop.ts +85 -12
- package/ts/src/state-ledger.ts +48 -4
package/ts/src/index.ts
CHANGED
|
@@ -38,6 +38,8 @@ import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
|
|
|
38
38
|
import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
|
|
39
39
|
import { flyaiSearch } from '../capabilities/flyai.ts'
|
|
40
40
|
import { sessionFlightSearch } from '../capabilities/session-search.ts'
|
|
41
|
+
import { sessionLogin } from '../capabilities/session-login.ts'
|
|
42
|
+
import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.ts'
|
|
41
43
|
|
|
42
44
|
export const name = 'gotry-tools'
|
|
43
45
|
export const inject = ['tools', 'systemPrompt']
|
|
@@ -49,12 +51,15 @@ export interface Config {
|
|
|
49
51
|
timeoutMs: number
|
|
50
52
|
/** hbcli 二进制路径(hotelbyte-cli;空=禁用实时酒店,回退数据包) */
|
|
51
53
|
hbcliBin: string
|
|
54
|
+
/** 账号会话检索总闸(RFC 支柱④「用户明示授权+随时可关」):ask=gotry_session_search 每会话每站点首次调用弹审批卡、会话内记住(默认);allow=用户已在配置明示预授权(直接放行);off=总闸关闭,直接拒绝 */
|
|
55
|
+
sessionAccess: string
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export const Config: z<Config> = z.object({
|
|
55
59
|
stateRoot: z.string().default('.'),
|
|
56
60
|
timeoutMs: z.number().default(30_000),
|
|
57
61
|
hbcliBin: z.string().default('hbcli'),
|
|
62
|
+
sessionAccess: z.string().default('ask'),
|
|
58
63
|
})
|
|
59
64
|
|
|
60
65
|
interface FeasibilityResult {
|
|
@@ -157,6 +162,21 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
157
162
|
// 不调 process.exit——让 dsh/上级容器决定生死,我们只留现场。
|
|
158
163
|
installProcessGuards(config.stateRoot ?? '.', { uncaughtException: 'gotry-tools', unhandledRejection: 'gotry-tools' })
|
|
159
164
|
|
|
165
|
+
// 账号会话授权闸(RFC 支柱④「用户明示授权 + 站点白名单 + 随时可关」进代码;v2 每会话一次):
|
|
166
|
+
// 会话面工具动用用户本人登录态,**每会话每站点首次调用**弹 dsh 原生审批卡——
|
|
167
|
+
// allowed-once 记入会话 granted 集,后续同站调用免弹;用户拒绝 = 本会话吊销
|
|
168
|
+
// (denied 集,不再弹卡也不再执行——拒绝是裁决,不反复骚扰);无审批通道
|
|
169
|
+
// (headless 无用户在场)一律 fail-closed 拒绝。sessionAccess: ask(默认)|allow|off。
|
|
170
|
+
// v1 教训(2026-08-29 founder 实测「每次都要弹,经常无法点击」):逐调用弹卡 = 骚扰,
|
|
171
|
+
// 会话态收归 capabilities/session-consent.ts。防御:极简宿主/mock ctx 无事件总线时跳过。
|
|
172
|
+
const ctxOn = (ctx as unknown as { on?: unknown }).on
|
|
173
|
+
if (typeof ctxOn === 'function') {
|
|
174
|
+
ctx.on('tools/pre-execute', createConsentGate({
|
|
175
|
+
access: () => config.sessionAccess ?? 'ask',
|
|
176
|
+
approval: approvalFromContext(ctx),
|
|
177
|
+
}))
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
// D-NEW 收尾:全部工具 execute 统一异常隔离——单个工具抛错/拒绝不再沿 cordis
|
|
161
181
|
// 传到 dsh 主循环,降级为结构化错误返回给 LLM + incident 落盘(incident-log.ts)。
|
|
162
182
|
const registerGuarded = (tool: ReturnType<typeof defineTool>): void => {
|
|
@@ -643,19 +663,60 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
643
663
|
registerGuarded(defineTool({
|
|
644
664
|
name: 'gotry_flyai_search',
|
|
645
665
|
description:
|
|
646
|
-
'Live
|
|
647
|
-
+ '
|
|
648
|
-
+ '
|
|
666
|
+
'Live travel search through the Fliggy official FlyAI channel (read-only, no key; booking/comparison happens by the HUMAN on the jumpUrl page). '
|
|
667
|
+
+ 'kind="flight"|"train": { kind, from, to, date } (中文城市名, date YYYY-MM-DD) — real schedules & prices, split 直达/中转 in results. '
|
|
668
|
+
+ 'kind="hotel": { kind:"hotel", to:"大理"(目的地中文), checkIn?, checkOut? (YYYY-MM-DD,成对可选——未定档期可不填先摸底), keyWords? }. '
|
|
669
|
+
+ 'Hotel prices may be masked upstream (priceRaw like "¥7xx"): always present the mask as a range, and let the human open jumpUrl for the real price. '
|
|
670
|
+
+ 'Evidence [实时API:flyai@ts]. Errors (rate-limit Sentinel / invalid dates) degrade as structured errors with the upstream message — surface them, never guess.',
|
|
649
671
|
parameters: {
|
|
650
|
-
query: { type: 'json', required: true, description: '
|
|
672
|
+
query: { type: 'json', required: true, description: 'kind=flight|train: { kind, from: "上海", to: "丽江", date: "2026-10-01" }; kind="hotel": { kind:"hotel", to:"大理", checkIn?: "YYYY-MM-DD", checkOut?: "YYYY-MM-DD", keyWords?: "洱海" }' },
|
|
651
673
|
},
|
|
652
674
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
653
675
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
654
|
-
const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string }>(args, 'from')
|
|
676
|
+
const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; checkIn?: string; checkOut?: string; keyWords?: string }>(args, 'from')
|
|
677
|
+
if (q.kind === 'hotel') {
|
|
678
|
+
const dest = (q.to ?? '').trim()
|
|
679
|
+
if (!dest) return { ok: false, summary: 'kind=hotel 需要 to(目的地中文,如 大理)' } as const
|
|
680
|
+
if ((q.checkIn ? 1 : 0) !== (q.checkOut ? 1 : 0) || (q.checkIn && !/^\d{4}-\d{2}-\d{2}$/.test(q.checkIn))) {
|
|
681
|
+
return { ok: false, summary: '酒店 checkIn/checkOut 须成对且为 YYYY-MM-DD(未定档期可不填,先摸底)' } as const
|
|
682
|
+
}
|
|
683
|
+
// 过去入住日同机/火过去日期闸(issue #24 同构;分层纪律:日期算术在代码层)
|
|
684
|
+
if (q.checkIn) {
|
|
685
|
+
const now = new Date()
|
|
686
|
+
const todayYmd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
687
|
+
if (q.checkIn < todayYmd || (q.checkOut ?? '') < q.checkIn) {
|
|
688
|
+
return JSON.parse(JSON.stringify({
|
|
689
|
+
ok: false, verdict: 'error', kind: 'hotel',
|
|
690
|
+
summary: `未发起查询:入住 ${q.checkIn}/退房 ${q.checkOut ?? ''} 不是未来合法区间(今天 ${todayYmd})。向用户确认日期后再查。`,
|
|
691
|
+
})) as Record<string, never>
|
|
692
|
+
}
|
|
693
|
+
if ((q.checkOut ?? '').length !== 10) {
|
|
694
|
+
return { ok: false, summary: 'checkOut 需 YYYY-MM-DD(与 checkIn 成对)' } as const
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const r = await flyaiSearch({ kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords })
|
|
698
|
+
const top = (r.hotels ?? []).slice(0, 8).map(o => `${o.name}${o.star ? `(${o.star})` : ''} ${o.priceRaw ?? '价待询'}${o.poi ? ` · ${o.poi}` : ''}`)
|
|
699
|
+
const summary = r.verdict === 'hit'
|
|
700
|
+
? `${dest} 酒店(飞猪官方只读)前 ${top.length} 家(价格多为打码,真实价以 jumpUrl 为准):\n${top.join('\n')}\n${r.evidence}`
|
|
701
|
+
: `${dest} 酒店无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
|
|
702
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
703
|
+
}
|
|
655
704
|
const kind = q.kind === 'train' ? 'train' : 'flight'
|
|
656
705
|
if (!q.from || !q.to || !q.date) {
|
|
657
706
|
return { ok: false, summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)' } as const
|
|
658
707
|
}
|
|
708
|
+
// 过去日期预校验(issue #24,代码层算术;分层纪律):用户说「7.18」未带年份时模型会落到当前年,
|
|
709
|
+
// 而今天可能已在 8 月——过去日期上游必拒(「出发日期非法」)。拦在发查询之前并指明修正方向,
|
|
710
|
+
// 不让模型从 miss 里猜因。
|
|
711
|
+
const now = new Date()
|
|
712
|
+
const todayYmd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
713
|
+
if (q.date < todayYmd) {
|
|
714
|
+
return JSON.parse(JSON.stringify({
|
|
715
|
+
ok: false, verdict: 'error', kind,
|
|
716
|
+
summary: `未发起查询:日期 ${q.date} 已是过去(今天 ${todayYmd}),过去不存在在售机/火车票。`
|
|
717
|
+
+ `多为用户时间表达未带年份所致——向用户确认年份(或按未来最近的同月日修正)后再查。`,
|
|
718
|
+
})) as Record<string, never>
|
|
719
|
+
}
|
|
659
720
|
const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
|
|
660
721
|
const top = (r.options ?? []).slice(0, 8).map(o => `${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
|
|
661
722
|
const summary = r.verdict === 'hit'
|
|
@@ -665,19 +726,54 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
665
726
|
},
|
|
666
727
|
presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
|
|
667
728
|
presentResult: (args, value) => {
|
|
668
|
-
const
|
|
669
|
-
|
|
729
|
+
const isHotel = String((args.query as { kind?: string })?.kind) === 'hotel'
|
|
730
|
+
const r = value as { ok?: boolean; options?: unknown[]; hotels?: unknown[] }
|
|
731
|
+
const n = Math.max((r.options ?? []).length, (r.hotels ?? []).length)
|
|
732
|
+
return { card: 'generic', title: `${isHotel ? '飞猪酒店' : '飞猪检索'}:${r.ok && n > 0 ? `${n} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
733
|
+
},
|
|
734
|
+
}))
|
|
735
|
+
|
|
736
|
+
registerGuarded(defineTool({
|
|
737
|
+
name: 'gotry_session_login',
|
|
738
|
+
description:
|
|
739
|
+
'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
|
|
740
|
+
+ 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. '
|
|
741
|
+
+ 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
|
|
742
|
+
+ 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). '
|
|
743
|
+
+ 'Evidence [会话:<site>-login@ts].',
|
|
744
|
+
parameters: {
|
|
745
|
+
query: { type: 'json', required: true, description: '可空对象 {}: { waitSeconds?: number }(等待用户完成登录的上限秒数,默认 90,至多 300)' },
|
|
746
|
+
},
|
|
747
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { evidence?: string }).evidence ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
748
|
+
async execute(args: { query?: unknown }, _exec: unknown) {
|
|
749
|
+
const q = unwrapQuery<{ waitSeconds?: number }>(args, 'waitSeconds')
|
|
750
|
+
const r = await sessionLogin({ waitMs: typeof q?.waitSeconds === 'number' ? q.waitSeconds * 1000 : undefined })
|
|
751
|
+
const summary = r.verdict === 'logged-in'
|
|
752
|
+
? `${r.site} 登录完成确认(票据 cookie 名已检出,只读名字)。说明:登录是在携程官网、用你自己的浏览器完成的——gotry 全程未接触任何密码/验证码/cookie 值。现在可以继续会话检索了。${r.evidence}`
|
|
753
|
+
: r.verdict === 'pending'
|
|
754
|
+
? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}`
|
|
755
|
+
: r.verdict === 'needs-attach'
|
|
756
|
+
? `需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"。${r.evidence}`
|
|
757
|
+
: `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`
|
|
758
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
759
|
+
},
|
|
760
|
+
presentCall: _args => ({ card: 'generic', title: '登录携程(用你自己的浏览器,不在 gotry 输入)', kind: 'fetch', rawInput: {} }),
|
|
761
|
+
presentResult: (_args, value) => {
|
|
762
|
+
const r = value as { verdict?: string; tickets?: string[] }
|
|
763
|
+
const label = r.verdict === 'logged-in' ? `已登录(${(r.tickets ?? []).length} 票据)` : r.verdict === 'pending' ? '等待你在携程页面完成登录' : r.verdict ?? '降级'
|
|
764
|
+
return { card: 'generic', title: `账号登录:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
670
765
|
},
|
|
671
766
|
}))
|
|
672
767
|
|
|
673
768
|
registerGuarded(defineTool({
|
|
674
769
|
name: 'gotry_session_search',
|
|
675
770
|
description:
|
|
676
|
-
'
|
|
677
|
-
+ '
|
|
771
|
+
'Search on the USER\'S OWN logged-in browser session (Ctrip flights today; the account channel, not an anonymous instance). '
|
|
772
|
+
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
773
|
+
+ 'ReadGuard = physically read-only (write requests aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
678
774
|
+ 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
|
|
679
|
-
+ 'verdict needs-login =
|
|
680
|
-
+ '
|
|
775
|
+
+ 'verdict needs-login = call gotry_session_login (product tool: opens the Ctrip login entry in the user\'s own Chrome and waits for them to log in on the external site — no terminal, no credentials through GoTry); needs-attach = one-time Chrome remote-debugging switch. '
|
|
776
|
+
+ 'Rate-limited (≥30s between same-site calls; a challenged/timeout verdict means STOP — never retry, fall back to other tools).',
|
|
681
777
|
parameters: {
|
|
682
778
|
query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
|
|
683
779
|
},
|
package/ts/src/loop.ts
CHANGED
|
@@ -110,6 +110,51 @@ export interface AsyncTicket {
|
|
|
110
110
|
etaLabel: string
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
export const ASYNC_TERMINAL_SCHEMA = 'gotry_async_terminal.v1' as const
|
|
114
|
+
|
|
115
|
+
export interface AsyncTerminalOutcome {
|
|
116
|
+
schema: typeof ASYNC_TERMINAL_SCHEMA
|
|
117
|
+
ticket_id: string
|
|
118
|
+
status: 'succeeded' | 'failed'
|
|
119
|
+
passed: number
|
|
120
|
+
total: 4
|
|
121
|
+
checks: Record<string, boolean>
|
|
122
|
+
failed_checks: string[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asyncTerminalOutcome(ticketId: string, checks: Record<string, boolean>): AsyncTerminalOutcome {
|
|
126
|
+
const failedChecks = Object.entries(checks).filter(([, passed]) => !passed).map(([name]) => name)
|
|
127
|
+
return {
|
|
128
|
+
schema: ASYNC_TERMINAL_SCHEMA,
|
|
129
|
+
ticket_id: ticketId,
|
|
130
|
+
status: failedChecks.length === 0 ? 'succeeded' : 'failed',
|
|
131
|
+
passed: Object.values(checks).filter(Boolean).length,
|
|
132
|
+
total: 4,
|
|
133
|
+
checks,
|
|
134
|
+
failed_checks: failedChecks,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function terminalOutcomeFromState(ticketId: string, state: TripState): AsyncTerminalOutcome {
|
|
139
|
+
if (!state.spec || !state.solve) {
|
|
140
|
+
return asyncTerminalOutcome(ticketId, {
|
|
141
|
+
'1_承诺时间后必有明确产物': false,
|
|
142
|
+
'2_产物通过自检清单': false,
|
|
143
|
+
'3_待决问题全部是简单选择题': false,
|
|
144
|
+
'4_做不到的诚实说': false,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return asyncTerminalOutcome(ticketId, {
|
|
149
|
+
'1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
|
|
150
|
+
'2_产物通过自检清单': state.solve.feasible
|
|
151
|
+
? (state.solve.legs?.every(l => (l as Record<string, unknown>)['energy_pct'] !== undefined) ?? false)
|
|
152
|
+
: Boolean(state.solve.unsat_core?.length),
|
|
153
|
+
'3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
|
|
154
|
+
'4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
export function isComplex(state: TripState): boolean {
|
|
114
159
|
/** 复杂度判据(架构占位,后续可细化):多段或多约束即深规划 */
|
|
115
160
|
const p = state.profile
|
|
@@ -134,21 +179,19 @@ export async function collectDeepPlanning(
|
|
|
134
179
|
state: TripState,
|
|
135
180
|
ticket: AsyncTicket,
|
|
136
181
|
solve: SolvePort,
|
|
137
|
-
): Promise<{ reply: string; state: TripState }> {
|
|
182
|
+
): Promise<{ reply: string; state: TripState; outcome: AsyncTerminalOutcome }> {
|
|
138
183
|
const spec = state.spec
|
|
139
|
-
if (!spec)
|
|
184
|
+
if (!spec) {
|
|
185
|
+
const outcome = terminalOutcomeFromState(ticket.id, state)
|
|
186
|
+
return { reply: '(内部状态缺失 spec——深度规划未就绪,这是不应发生的路径)', state, outcome }
|
|
187
|
+
}
|
|
140
188
|
state.solve = await solve(spec)
|
|
141
189
|
state.gates = state.gates.filter(g => !g.id.startsWith('async-'))
|
|
142
190
|
|
|
143
191
|
// 不失望四条自检(交付物自带,总纲 3.6)
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
'3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
|
|
148
|
-
'4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
|
|
149
|
-
}
|
|
150
|
-
const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${Object.values(checks).every(Boolean) ? '4/4 ✅' : '有未达项 ❌'})`
|
|
151
|
-
return { reply: `${head}\n\n${renderSolve(state)}`, state }
|
|
192
|
+
const outcome = terminalOutcomeFromState(ticket.id, state)
|
|
193
|
+
const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${outcome.status === 'succeeded' ? '4/4 ✅' : '有未达项 ❌'})`
|
|
194
|
+
return { reply: `${head}\n\n${renderSolve(state)}`, state, outcome }
|
|
152
195
|
}
|
|
153
196
|
|
|
154
197
|
/** 求解结果 → 人话(模板;S4 可由 LLM 润色,数字与判定不可改) */
|
|
@@ -341,14 +384,44 @@ export async function loadAsyncTicket(ticketId: string, stateRoot = '.'): Promis
|
|
|
341
384
|
}
|
|
342
385
|
}
|
|
343
386
|
|
|
344
|
-
export async function settleAsyncTicket(
|
|
345
|
-
|
|
387
|
+
export async function settleAsyncTicket(
|
|
388
|
+
ticketId: string,
|
|
389
|
+
reply: string,
|
|
390
|
+
stateRoot = '.',
|
|
391
|
+
outcome?: AsyncTerminalOutcome,
|
|
392
|
+
): Promise<string> {
|
|
393
|
+
const ledger = openLedgerIfExists(stateRoot)
|
|
394
|
+
const terminalOutcome = outcome ?? recoverAsyncTerminalOutcome(ledger, ticketId)
|
|
395
|
+
if (ledger && !terminalOutcome) {
|
|
396
|
+
throw new Error(`workflow ${ticketId} 缺少可恢复的 ${ASYNC_TERMINAL_SCHEMA},拒绝误结算`)
|
|
397
|
+
}
|
|
398
|
+
if (terminalOutcome?.status === 'failed') ledger?.failWorkflowRun(ticketId, reply, terminalOutcome)
|
|
399
|
+
else ledger?.settleWorkflowRun(ticketId, reply, terminalOutcome)
|
|
346
400
|
const dir = await asyncDir(stateRoot)
|
|
347
401
|
const p = join(dir, `${ticketId}.deliverable.md`)
|
|
348
402
|
await atomicWrite(p, reply)
|
|
349
403
|
return p
|
|
350
404
|
}
|
|
351
405
|
|
|
406
|
+
function recoverAsyncTerminalOutcome(ledger: StateLedger | null, ticketId: string): AsyncTerminalOutcome | undefined {
|
|
407
|
+
if (!ledger) return undefined
|
|
408
|
+
const run = ledger.getWorkflowRun(ticketId)
|
|
409
|
+
if (!run) return undefined
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const state = JSON.parse(run.state_json) as TripState
|
|
413
|
+
if (!state.spec) return terminalOutcomeFromState(ticketId, state)
|
|
414
|
+
|
|
415
|
+
const solveStep = ledger.getWorkflowStep(ticketId, 'solve')
|
|
416
|
+
if (solveStep?.status !== 'done' || !solveStep.result) return undefined
|
|
417
|
+
state.solve = JSON.parse(solveStep.result) as SolveResult
|
|
418
|
+
state.gates = state.gates.filter(g => !g.id.startsWith('async-'))
|
|
419
|
+
return terminalOutcomeFromState(ticketId, state)
|
|
420
|
+
} catch {
|
|
421
|
+
return undefined
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
352
425
|
/**
|
|
353
426
|
* 可日志化 solve 端口(ADR-15 步骤日志,intent-before-execute):
|
|
354
427
|
* 先记 intent 再执行,done 后结果落账本——任意进程恢复时 done 的步骤直接复用
|
package/ts/src/state-ledger.ts
CHANGED
|
@@ -544,15 +544,59 @@ export class StateLedger {
|
|
|
544
544
|
).run(JSON.stringify(result), new Date().toISOString(), runId, name, this.tenant)
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
-
|
|
547
|
+
private finishWorkflowRun(
|
|
548
|
+
id: string,
|
|
549
|
+
status: 'settled' | 'failed',
|
|
550
|
+
eventKind: 'async.settled' | 'async.failed',
|
|
551
|
+
deliverable: string,
|
|
552
|
+
terminalOutcome?: unknown,
|
|
553
|
+
actor = 'system:async-collect',
|
|
554
|
+
): void {
|
|
548
555
|
const run = this.db.transaction(() => {
|
|
549
|
-
this.db.prepare(
|
|
550
|
-
|
|
551
|
-
|
|
556
|
+
const update = this.db.prepare(
|
|
557
|
+
`UPDATE workflow_runs SET status = ?, deliverable = ?, updated = ?
|
|
558
|
+
WHERE id = ? AND tenant_id = ? AND status = 'pending'`,
|
|
559
|
+
).run(status, deliverable, new Date().toISOString(), id, this.tenant)
|
|
560
|
+
if (update.changes === 0) {
|
|
561
|
+
const existing = this.getWorkflowRun(id)
|
|
562
|
+
if (existing?.status === status) return
|
|
563
|
+
throw new Error(`workflow ${id} cannot transition ${existing?.status ?? 'missing'} -> ${status}`)
|
|
564
|
+
}
|
|
565
|
+
this.insertEvent({
|
|
566
|
+
actor,
|
|
567
|
+
kind: eventKind,
|
|
568
|
+
subjectId: id,
|
|
569
|
+
payload: { bytes: deliverable.length, terminal_outcome: terminalOutcome },
|
|
570
|
+
idemKey: `async-terminal:${id}`,
|
|
571
|
+
runId: id,
|
|
572
|
+
})
|
|
552
573
|
})
|
|
553
574
|
run()
|
|
554
575
|
}
|
|
555
576
|
|
|
577
|
+
settleWorkflowRun(id: string, deliverable: string, terminalOutcome?: unknown, actor = 'system:async-collect'): void {
|
|
578
|
+
this.finishWorkflowRun(id, 'settled', 'async.settled', deliverable, terminalOutcome, actor)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
failWorkflowRun(id: string, deliverable: string, terminalOutcome: unknown, actor = 'system:async-collect'): void {
|
|
582
|
+
this.finishWorkflowRun(id, 'failed', 'async.failed', deliverable, terminalOutcome, actor)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
getWorkflowTerminalOutcome(id: string): Record<string, unknown> | null {
|
|
586
|
+
const row = this.db.prepare(
|
|
587
|
+
`SELECT payload FROM events
|
|
588
|
+
WHERE tenant_id = ? AND run_id = ? AND kind IN ('async.settled', 'async.failed')
|
|
589
|
+
ORDER BY seq DESC LIMIT 1`,
|
|
590
|
+
).get(this.tenant, id) as { payload: string } | undefined
|
|
591
|
+
if (!row) return null
|
|
592
|
+
try {
|
|
593
|
+
const payload = JSON.parse(row.payload) as { terminal_outcome?: Record<string, unknown> }
|
|
594
|
+
return payload.terminal_outcome ?? null
|
|
595
|
+
} catch {
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
556
600
|
// ---- WriteGate saga 基座(TS-4, RFC S4 L2/L3 物理预备) -----------------------
|
|
557
601
|
|
|
558
602
|
/** 登记一笔待确认外部写(幂等键去重:同一确认不可能登记两次)。L2 = 只登记不执行。 */
|