@x-otto/cli 0.0.1-alpha.5 → 0.0.1-alpha.50
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 +72 -65
- package/bin/otto.js +6 -0
- package/dist/{auth-command-CXAB3_hs.js → auth-command-BYaFM76w.js} +3 -3
- package/dist/auth-command-BYaFM76w.js.map +1 -0
- package/dist/backend-handler-DUafuNWw.js +3 -0
- package/dist/backend-handler-DUafuNWw.js.map +1 -0
- package/dist/build-id-DjE6eVpQ.js +3 -0
- package/dist/build-id-DjE6eVpQ.js.map +1 -0
- package/dist/bundled-extensions/plugin-feedback/otto-plugin.json +24 -0
- package/dist/bundled-extensions/plugin-feedback/panels/feedback.tsx +348 -0
- package/dist/bundled-extensions/plugin-feedback/panels/panel-host-globals.d.ts +67 -0
- package/dist/bundled-extensions/plugin-feedback/panels-dist/feedback.mjs +257 -0
- package/dist/bundled-extensions/plugin-feedback/plugin-dist/meta.json +1 -0
- package/dist/bundled-extensions/plugin-feedback/plugin-dist/plugin.cjs +11304 -0
- package/dist/bundled-extensions/plugin-feedback/plugin.ts +198 -0
- package/dist/bundled-extensions/plugin-feedback/src/coding-adapter.ts +183 -0
- package/dist/bundled-extensions/plugin-feedback/src/coding-lister.ts +26 -0
- package/dist/bundled-extensions/plugin-feedback/src/github-lister.ts +230 -0
- package/dist/bundled-extensions/plugin-feedback/src/issue-aggregator.ts +109 -0
- package/dist/bundled-extensions/plugin-feedback/src/issue-submitter.ts +241 -0
- package/dist/bundled-extensions/plugin-feedback/src/platform-resolver.ts +76 -0
- package/dist/bundled-extensions/plugin-feedback/src/pulse-survey-sink.ts +114 -0
- package/dist/bundled-extensions/plugin-feedback/src/resolved-sink.ts +163 -0
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/meta.json +1 -1
- package/dist/bundled-extensions/plugin-otto-wire-protocols/plugin-dist/plugin.cjs +1019 -43188
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/anthropic-messages.ts +6 -24
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-completions.ts +17 -10
- package/dist/bundled-extensions/plugin-otto-wire-protocols/src/openai-responses.ts +12 -17
- package/dist/bundled-extensions-C3n8jg20.js +3 -0
- package/dist/bundled-extensions-C3n8jg20.js.map +1 -0
- package/dist/command-dispatcher-BLT8IO4K.js +18 -0
- package/dist/command-dispatcher-BLT8IO4K.js.map +1 -0
- package/dist/config-migration-DatflIxs.js.map +1 -1
- package/dist/create-host-app-CbJ8ptv-.js +3 -0
- package/dist/create-host-app-CbJ8ptv-.js.map +1 -0
- package/dist/{daemon-base-YOWgYYN7.js → daemon-base-D015ew5y.js} +1 -1
- package/dist/{daemon-base-YOWgYYN7.js.map → daemon-base-D015ew5y.js.map} +1 -1
- package/dist/discover-with-bundled-B5d4q___.js +2 -0
- package/dist/discover-with-bundled-CF8qFP41.js +3 -0
- package/dist/discover-with-bundled-CF8qFP41.js.map +1 -0
- package/dist/doctor-EndB-ycb.js +36 -0
- package/dist/doctor-EndB-ycb.js.map +1 -0
- package/dist/{doctor-attach-_zeIJewx.js → doctor-attach-DEoE7eFx.js} +2 -2
- package/dist/{doctor-attach-_zeIJewx.js.map → doctor-attach-DEoE7eFx.js.map} +1 -1
- package/dist/{doctor-cdp-K_mIFRfA.js → doctor-cdp-Cj9IFpEP.js} +1 -1
- package/dist/{doctor-cdp-K_mIFRfA.js.map → doctor-cdp-Cj9IFpEP.js.map} +1 -1
- package/dist/{doctor-heap-Rr2nNvcw.js → doctor-heap-SIxiRvL8.js} +2 -2
- package/dist/{doctor-heap-Rr2nNvcw.js.map → doctor-heap-SIxiRvL8.js.map} +1 -1
- package/dist/{doctor-ps-OL3FGQYu.js → doctor-ps-DNVOF6yY.js} +2 -2
- package/dist/{doctor-ps-OL3FGQYu.js.map → doctor-ps-DNVOF6yY.js.map} +1 -1
- package/dist/export-trajectory-D-BcH0Wt.js +14 -0
- package/dist/export-trajectory-D-BcH0Wt.js.map +1 -0
- package/dist/extension-CoJqqktf.js +2 -0
- package/dist/extension-Dpn8gltG.js +179 -0
- package/dist/extension-Dpn8gltG.js.map +1 -0
- package/dist/feedback-QcONRnO3.js +30 -0
- package/dist/feedback-QcONRnO3.js.map +1 -0
- package/dist/feedback-backend-methods-BNm38ddr.js +3 -0
- package/dist/feedback-backend-methods-BNm38ddr.js.map +1 -0
- package/dist/{feishu-setup-D6vnuNLw.js → feishu-setup-DbAq_BRH.js} +2 -2
- package/dist/feishu-setup-DbAq_BRH.js.map +1 -0
- package/dist/ftue-doctor-recovery-CKTMnxM8.js +3 -0
- package/dist/ftue-doctor-recovery-CKTMnxM8.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -14
- package/dist/index.js.map +1 -1
- package/dist/install-CEPczuet.js +11 -0
- package/dist/install-CEPczuet.js.map +1 -0
- package/dist/install-CG6b6fox.js +2 -0
- package/dist/interactive-schedule-Bv__2Kwc.js +3 -0
- package/dist/interactive-schedule-Bv__2Kwc.js.map +1 -0
- package/dist/{mcp-DbTYT7hB.js → mcp-DjReRjuu.js} +1 -1
- package/dist/{mcp-DbTYT7hB.js.map → mcp-DjReRjuu.js.map} +1 -1
- package/dist/mcp-server-Rr__aWO7.js +5 -0
- package/dist/mcp-server-Rr__aWO7.js.map +1 -0
- package/dist/{migrate-boDXhEyV.js → migrate-BBi3Txu2.js} +1 -1
- package/dist/{migrate-boDXhEyV.js.map → migrate-BBi3Txu2.js.map} +1 -1
- package/dist/observe-BqLCpmFD.js +83 -0
- package/dist/observe-BqLCpmFD.js.map +1 -0
- package/dist/parse-cli-Db8pF_Ya.js +3 -0
- package/dist/parse-cli-Db8pF_Ya.js.map +1 -0
- package/dist/persistence-BvLRnpfi.js +85 -0
- package/dist/persistence-BvLRnpfi.js.map +1 -0
- package/dist/plugin-assembly-check-BntT5W6H.js +3 -0
- package/dist/plugin-assembly-check-BntT5W6H.js.map +1 -0
- package/dist/{proxy-evg7ZvoD.js → proxy-CFZDVIwI.js} +1 -1
- package/dist/proxy-CFZDVIwI.js.map +1 -0
- package/dist/proxy-CkSf8ziQ.js +2 -0
- package/dist/{ps-DKrluEuy.js → ps-BVKv6rAo.js} +1 -1
- package/dist/{ps-DKrluEuy.js.map → ps-BVKv6rAo.js.map} +1 -1
- package/dist/{registry-D0NONjLv.js → registry-BUvu-es1.js} +4 -4
- package/dist/registry-BUvu-es1.js.map +1 -0
- package/dist/registry-DYHR-ONu.js +2 -0
- package/dist/{run-attach-Ci5jYyCa.js → run-attach-X35puJ_n.js} +2 -2
- package/dist/{run-attach-Ci5jYyCa.js.map → run-attach-X35puJ_n.js.map} +1 -1
- package/dist/schedule-Ke0jwatM.js +5 -0
- package/dist/schedule-Ke0jwatM.js.map +1 -0
- package/dist/serve-K6BOT5K_.js +21 -0
- package/dist/serve-K6BOT5K_.js.map +1 -0
- package/dist/{service-client-BLNeugM9.js → service-client-cfUUP4EE.js} +1 -1
- package/dist/{service-client-BLNeugM9.js.map → service-client-cfUUP4EE.js.map} +1 -1
- package/dist/{version-C0qM-qJM.js → version-DrCy32wR.js} +2 -2
- package/dist/{version-C0qM-qJM.js.map → version-DrCy32wR.js.map} +1 -1
- package/package.json +28 -25
- package/dist/auth-command-CXAB3_hs.js.map +0 -1
- package/dist/build-id-BX4QHzM0.js +0 -3
- package/dist/build-id-BX4QHzM0.js.map +0 -1
- package/dist/bundled-extensions/plugin-skill-inductor/otto-plugin.json +0 -8
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/meta.json +0 -1
- package/dist/bundled-extensions/plugin-skill-inductor/plugin-dist/plugin.cjs +0 -52781
- package/dist/bundled-extensions/plugin-skill-inductor/plugin.ts +0 -77
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-detector.ts +0 -210
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-digest.ts +0 -146
- package/dist/bundled-extensions/plugin-skill-inductor/src/candidate-store.ts +0 -87
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode-store.ts +0 -122
- package/dist/bundled-extensions/plugin-skill-inductor/src/episode.ts +0 -125
- package/dist/bundled-extensions/plugin-skill-inductor/src/extraction-prompt.ts +0 -97
- package/dist/bundled-extensions/plugin-skill-inductor/src/inductor-service.ts +0 -183
- package/dist/bundled-extensions/plugin-skill-inductor/src/similarity.ts +0 -58
- package/dist/command-dispatcher-BVGddstc.js +0 -13
- package/dist/command-dispatcher-BVGddstc.js.map +0 -1
- package/dist/debug-CiosFwme.js +0 -34
- package/dist/debug-CiosFwme.js.map +0 -1
- package/dist/discover-with-bundled-CznLjkkw.js +0 -3
- package/dist/discover-with-bundled-CznLjkkw.js.map +0 -1
- package/dist/doctor-C84-hhEr.js +0 -30
- package/dist/doctor-C84-hhEr.js.map +0 -1
- package/dist/extension-BKXIr86F.js +0 -170
- package/dist/extension-BKXIr86F.js.map +0 -1
- package/dist/extension-CnON6Xum.js +0 -2
- package/dist/extension-plugin-DB-bWOIf.js +0 -7
- package/dist/extension-plugin-DB-bWOIf.js.map +0 -1
- package/dist/extension-plugin-DoKKFxRC.js +0 -2
- package/dist/feedback-BES_6ebi.js +0 -21
- package/dist/feedback-BES_6ebi.js.map +0 -1
- package/dist/feedback-BRbLUn9u.js +0 -2
- package/dist/feishu-setup-D6vnuNLw.js.map +0 -1
- package/dist/install-DlVJRHRT.js +0 -11
- package/dist/install-DlVJRHRT.js.map +0 -1
- package/dist/install-WCcQ9k9M.js +0 -2
- package/dist/model-auth-B95mFA-O.js +0 -2
- package/dist/model-auth-BlaZzUUK.js +0 -3
- package/dist/model-auth-BlaZzUUK.js.map +0 -1
- package/dist/observe-CVg-MRM2.js +0 -32
- package/dist/observe-CVg-MRM2.js.map +0 -1
- package/dist/parse-cli-cYMjnAum.js +0 -3
- package/dist/parse-cli-cYMjnAum.js.map +0 -1
- package/dist/persistenced-CCwHu1Ww.js +0 -36
- package/dist/persistenced-CCwHu1Ww.js.map +0 -1
- package/dist/proxy-BvdBsB1O.js +0 -2
- package/dist/proxy-evg7ZvoD.js.map +0 -1
- package/dist/registry-B3nfN62q.js +0 -2
- package/dist/registry-D0NONjLv.js.map +0 -1
- package/dist/remote-persistence-server-B_hONoVR.js +0 -35
- package/dist/remote-persistence-server-B_hONoVR.js.map +0 -1
- package/dist/serve-pVR6UIT9.js +0 -21
- package/dist/serve-pVR6UIT9.js.map +0 -1
- package/dist/time-travel-ZozuPPI-.js +0 -21
- package/dist/time-travel-ZozuPPI-.js.map +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* issue-aggregator.ts — RFC-430 §2.4/§2.5:github/coding 双源并行聚合。
|
|
3
|
+
*
|
|
4
|
+
* 语义(与 RFC-430 §2.4 `FeedbackIssueListResult` 契约一一对应):
|
|
5
|
+
* - 双源并行拉取(Promise.allSettled 风格——单源异常/拒绝不传播、不拖垮另一源);
|
|
6
|
+
* - 单源 8s 硬超时(超时该源按失败处理,reason 标明 timeout);
|
|
7
|
+
* - 任一源失败/无凭据/未配置 → 该源 items 为空且 `unavailable[source]` 携带
|
|
8
|
+
* 面向用户的原因文案(只含失败类别/HTTP 状态码,绝无 token);
|
|
9
|
+
* - 双源全败不抛——返回 `{ items: [], unavailable: { github, coding } }`。
|
|
10
|
+
*
|
|
11
|
+
* 排序:updatedAt(缺省 createdAt)降序,缺失时间戳的条目排最后但保持稳定序。
|
|
12
|
+
*/
|
|
13
|
+
import type { FeedbackIssueItem, FeedbackIssueListResult, FeedbackSource } from '@x-otto/plugin'
|
|
14
|
+
|
|
15
|
+
/** 单源拉取硬超时(ms,RFC-430 §2.4)。 */
|
|
16
|
+
export const PER_SOURCE_TIMEOUT_MS = 8000
|
|
17
|
+
|
|
18
|
+
/** 与 github-lister/coding-lister 同构的单源拉取函数形状。 */
|
|
19
|
+
export type SourceLister = (
|
|
20
|
+
opts: { state: 'open' | 'closed' | 'all'; limit: number },
|
|
21
|
+
) => Promise<{ ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }>
|
|
22
|
+
|
|
23
|
+
export interface AggregateDeps {
|
|
24
|
+
github: SourceLister
|
|
25
|
+
coding: SourceLister
|
|
26
|
+
/** 单源超时注入点(测试用极小值模拟超时路径),缺省 8000。 */
|
|
27
|
+
timeoutMs?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 给单源拉取包一层硬超时——超时按失败处理(竞速 loser 的迟到结果被丢弃)。 */
|
|
31
|
+
function withTimeout(
|
|
32
|
+
lister: SourceLister,
|
|
33
|
+
opts: { state: 'open' | 'closed' | 'all'; limit: number },
|
|
34
|
+
timeoutMs: number,
|
|
35
|
+
): Promise<{ ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }> {
|
|
36
|
+
return new Promise((resolvePromise) => {
|
|
37
|
+
let settled = false
|
|
38
|
+
const settle = (v: { ok: true; items: FeedbackIssueItem[] } | { ok: false; reason: string }): void => {
|
|
39
|
+
if (settled) return
|
|
40
|
+
settled = true
|
|
41
|
+
resolvePromise(v)
|
|
42
|
+
}
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
settle({ ok: false, reason: `timeout after ${timeoutMs}ms` })
|
|
45
|
+
}, timeoutMs)
|
|
46
|
+
// unref:聚合调用方提前返回时,挂起的计时不拖住进程退出。
|
|
47
|
+
timer.unref?.()
|
|
48
|
+
lister(opts).then(
|
|
49
|
+
(v) => { clearTimeout(timer); settle(v) },
|
|
50
|
+
(error) => {
|
|
51
|
+
clearTimeout(timer)
|
|
52
|
+
settle({ ok: false, reason: `unexpected error: ${error instanceof Error ? error.name : 'unknown'}` })
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sortByUpdatedDesc(items: FeedbackIssueItem[]): FeedbackIssueItem[] {
|
|
59
|
+
// 稳定排序(V8 Array.sort 稳定):无时间戳的 key = 0 沉底,同值保持原相对序。
|
|
60
|
+
const key = (it: FeedbackIssueItem): number => {
|
|
61
|
+
const ts = Date.parse(it.updatedAt ?? it.createdAt ?? '')
|
|
62
|
+
return Number.isNaN(ts) ? 0 : ts
|
|
63
|
+
}
|
|
64
|
+
return [...items].sort((a, b) => key(b) - key(a))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 双源并行聚合主入口。任何单源失败都只反映在该源的 unavailable 文案上,
|
|
69
|
+
* 不影响另一源结果;本函数自身绝不抛出。
|
|
70
|
+
*/
|
|
71
|
+
export async function aggregateIssueLists(
|
|
72
|
+
opts: { state?: 'open' | 'closed' | 'all'; limitPerSource?: number } = {},
|
|
73
|
+
deps: AggregateDeps,
|
|
74
|
+
): Promise<FeedbackIssueListResult> {
|
|
75
|
+
const state = opts.state ?? 'open'
|
|
76
|
+
const limit = opts.limitPerSource ?? 20
|
|
77
|
+
const timeoutMs = deps.timeoutMs ?? PER_SOURCE_TIMEOUT_MS
|
|
78
|
+
|
|
79
|
+
const sources: Array<{ source: FeedbackSource; lister: SourceLister }> = [
|
|
80
|
+
{ source: 'github', lister: deps.github },
|
|
81
|
+
{ source: 'coding', lister: deps.coding },
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
const settled = await Promise.allSettled(
|
|
85
|
+
sources.map(({ lister }) => withTimeout(lister, { state, limit }, timeoutMs)),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const items: FeedbackIssueItem[] = []
|
|
89
|
+
const unavailable: Partial<Record<FeedbackSource, string>> = {}
|
|
90
|
+
settled.forEach((result, i) => {
|
|
91
|
+
const source = sources[i]!.source
|
|
92
|
+
if (result.status === 'fulfilled' && result.value.ok) {
|
|
93
|
+
items.push(...result.value.items)
|
|
94
|
+
} else if (result.status === 'fulfilled' && !result.value.ok) {
|
|
95
|
+
// `!ok` 显式写出才能让 TS 把联合收窄到 `{ ok: false; reason: string }`——
|
|
96
|
+
// 仅靠外层 else 不参与判别式收窄(result.value 是新的属性访问路径)。
|
|
97
|
+
unavailable[source] = result.value.reason
|
|
98
|
+
} else if (result.status === 'rejected') {
|
|
99
|
+
// Promise.allSettled 的 rejected 分支:lister 自身抛异常(防御——正常路径
|
|
100
|
+
// withTimeout 内部已把异常归一化为 ok:false,此处是双保险)。
|
|
101
|
+
unavailable[source] = `unexpected error: ${String(result.reason)}`
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
items: sortByUpdatedDesc(items),
|
|
107
|
+
...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* issue-submitter.ts — RFC-207/RFC-209 §2.5:GitHub issue 双通道提交逻辑。
|
|
3
|
+
*
|
|
4
|
+
* `submitIssueViaGh`(仅供 TUI/CLI 本地进程调用,可能走 gh CLI)与 `buildIssueUrl`
|
|
5
|
+
* (纯函数、两端共用、不执行任何子进程/网络调用)是接口层面物理分离的两个方法——
|
|
6
|
+
* 防止 web-ui 浏览器请求误触发服务器侧 gh CLI 调用(RFC-209 §2.1/§2.5/重要事项规则7)。
|
|
7
|
+
*
|
|
8
|
+
* body 模板结构对齐 RFC-207 §3.1;URL 长度保护对齐 §3.3(浏览器通道 6000 字符阈值);
|
|
9
|
+
* gh CLI 通道的临时文件生命周期对齐 §2.6a(0700 目录/0600 文件/UUID 文件名/close+exit
|
|
10
|
+
* 双重清理);错误分类表对齐 §2.7(R3 修订的 5 种失败模式);label 失败重试对齐 §2.7(R8)。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
|
|
17
|
+
import { isShadowModeEnabled, OTTO_HOME } from '@x-otto/env'
|
|
18
|
+
import { buildAllowedEnv } from '@x-otto/shared'
|
|
19
|
+
|
|
20
|
+
import type { FeedbackIssueInput, FeedbackIssueResult } from '@x-otto/plugin'
|
|
21
|
+
import type { FeedbackLoggerPort } from './pulse-survey-sink'
|
|
22
|
+
|
|
23
|
+
/** 宿主注入的日志端口(RFC-185 D3,同 pulse-survey-sink 的 FeedbackLoggerPort);缺省 no-op。 */
|
|
24
|
+
const noopLogger: FeedbackLoggerPort = { warn: () => {} }
|
|
25
|
+
|
|
26
|
+
/** GitHub issue body 服务端上限约 65536 字符;留安全余量(RFC-207 §2.6b)。 */
|
|
27
|
+
const MAX_BODY_CHARS = 60000
|
|
28
|
+
/** GitHub issues/new 查询参数 URL 长度安全阈值(RFC-207 §3.3,留余量于常见 8KB 限制之下)。 */
|
|
29
|
+
const MAX_URL_CHARS = 6000
|
|
30
|
+
/** gh 探测/子进程等待超时(毫秒,RFC-207 §2.7 R6 修订)。 */
|
|
31
|
+
const GH_AUTH_STATUS_TIMEOUT_MS = 5000
|
|
32
|
+
|
|
33
|
+
/** 按 RFC-207 §3.1 模板拼装 issue body(两通道共用)。 */
|
|
34
|
+
function buildIssueBody(input: FeedbackIssueInput): string {
|
|
35
|
+
const body = [
|
|
36
|
+
'## Reproduction Steps',
|
|
37
|
+
input.reproduce || '*(not provided)*',
|
|
38
|
+
'',
|
|
39
|
+
'## Expected Behavior',
|
|
40
|
+
input.expected || '*(not provided)*',
|
|
41
|
+
'',
|
|
42
|
+
'## Actual Behavior',
|
|
43
|
+
input.actual || '*(not provided)*',
|
|
44
|
+
'',
|
|
45
|
+
'## Environment',
|
|
46
|
+
input.envInfo,
|
|
47
|
+
'',
|
|
48
|
+
'---',
|
|
49
|
+
'*Generated by `otto feedback` — edit this issue directly to add more details.*',
|
|
50
|
+
].join('\n')
|
|
51
|
+
return capBodySize(body)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** RFC-207 §2.6b:body 超长截断保护,两通道统一走本函数。 */
|
|
55
|
+
function capBodySize(body: string, limit = MAX_BODY_CHARS): string {
|
|
56
|
+
if (body.length <= limit) return body
|
|
57
|
+
const suffix = '\n\n*(Content truncated — please edit the issue to paste the full details after creation)*'
|
|
58
|
+
return body.slice(0, limit - suffix.length) + suffix
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 两端共用——只构造预填 URL 字符串,不执行任何子进程/网络调用,纯函数式(RFC-209 §2.1)。
|
|
63
|
+
* URL 超限时截断 body 并追加提示(RFC-207 §3.3),完整 body 需调用方另行展示(如终端打印)。
|
|
64
|
+
*/
|
|
65
|
+
export function buildIssueUrl(input: FeedbackIssueInput): string {
|
|
66
|
+
const title = `[otto] ${input.title}`
|
|
67
|
+
let body = buildIssueBody(input)
|
|
68
|
+
let url = toGithubUrl(input.repo, title, body)
|
|
69
|
+
|
|
70
|
+
if (url.length > MAX_URL_CHARS) {
|
|
71
|
+
const suffix = '\n\n*(Content too long — full content available in clipboard/terminal output; please paste manually)*'
|
|
72
|
+
// 迭代收窄直到 URL 落在阈值内(body 截断量与 URL 编码膨胀率非线性,简单二分足够)。
|
|
73
|
+
let low = 0
|
|
74
|
+
let high = body.length
|
|
75
|
+
while (low < high) {
|
|
76
|
+
const mid = Math.floor((low + high) / 2)
|
|
77
|
+
const candidate = body.slice(0, mid) + suffix
|
|
78
|
+
const candidateUrl = toGithubUrl(input.repo, title, candidate)
|
|
79
|
+
if (candidateUrl.length <= MAX_URL_CHARS) {
|
|
80
|
+
low = mid + 1
|
|
81
|
+
} else {
|
|
82
|
+
high = mid
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
body = body.slice(0, Math.max(0, low - 1)) + suffix
|
|
86
|
+
url = toGithubUrl(input.repo, title, body)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return url
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toGithubUrl(repo: string, title: string, body: string): string {
|
|
93
|
+
const params = new URLSearchParams({ title, body, labels: 'from-cli' })
|
|
94
|
+
return `https://github.com/${repo}/issues/new?${params.toString()}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** RFC-207 §2.6a:临时文件目录(~/.otto/tmp/,非共享系统级 os.tmpdir(),最小暴露面)。 */
|
|
98
|
+
function feedbackTmpDir(): string {
|
|
99
|
+
return join(OTTO_HOME, 'tmp')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 写入 0600 权限的临时 body 文件,返回路径 + 清理句柄(RFC-207 §2.6a)。 */
|
|
103
|
+
function writeTempBodyFile(body: string): { path: string; cleanup: () => void } {
|
|
104
|
+
const dir = feedbackTmpDir()
|
|
105
|
+
if (!existsSync(dir)) {
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
107
|
+
}
|
|
108
|
+
const path = join(dir, `feedback-${randomUUID()}.md`)
|
|
109
|
+
writeFileSync(path, body, { mode: 0o600 })
|
|
110
|
+
let cleaned = false
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
if (cleaned) return
|
|
113
|
+
cleaned = true
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(path)
|
|
116
|
+
} catch {
|
|
117
|
+
// fail-soft:清理失败不影响主流程(孤儿文件留存,非安全关键——0600 权限已限制访问)。
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { path, cleanup }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 探测 gh 是否已登录(RFC-207 §2.7),5 秒超时视为不可用。 */
|
|
124
|
+
function checkGhAuthStatus(): Promise<boolean> {
|
|
125
|
+
return new Promise((resolvePromise) => {
|
|
126
|
+
let settled = false
|
|
127
|
+
const child = spawn('gh', ['auth', 'status'], { env: buildAllowedEnv() })
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
if (settled) return
|
|
130
|
+
settled = true
|
|
131
|
+
child.kill('SIGTERM')
|
|
132
|
+
resolvePromise(false)
|
|
133
|
+
}, GH_AUTH_STATUS_TIMEOUT_MS)
|
|
134
|
+
child.on('error', () => {
|
|
135
|
+
if (settled) return
|
|
136
|
+
settled = true
|
|
137
|
+
clearTimeout(timer)
|
|
138
|
+
resolvePromise(false)
|
|
139
|
+
})
|
|
140
|
+
child.on('exit', (code) => {
|
|
141
|
+
if (settled) return
|
|
142
|
+
settled = true
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
resolvePromise(code === 0)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** RFC-207 §2.7 R3 修订:gh issue create stderr 分类。 */
|
|
150
|
+
function classifyGhError(stderr: string): { kind: string; detail: string } {
|
|
151
|
+
if (/HTTP 404|HTTP 403/.test(stderr)) {
|
|
152
|
+
return { kind: 'repo-not-found-or-forbidden', detail: stderr }
|
|
153
|
+
}
|
|
154
|
+
if (/Issues has been disabled/i.test(stderr)) {
|
|
155
|
+
return { kind: 'issues-disabled', detail: stderr }
|
|
156
|
+
}
|
|
157
|
+
if (/unknown flag/i.test(stderr)) {
|
|
158
|
+
return { kind: 'gh-version-too-old', detail: stderr }
|
|
159
|
+
}
|
|
160
|
+
if (/dial tcp|no such host/i.test(stderr)) {
|
|
161
|
+
return { kind: 'network-error', detail: stderr }
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'unclassified', detail: stderr }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 是否为 label 相关失败(RFC-207 §2.7 R8:不存在的 label 重试一次不带 label)。 */
|
|
167
|
+
function isLabelError(stderr: string): boolean {
|
|
168
|
+
return /label/i.test(stderr) && /not found/i.test(stderr)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function runGhIssueCreate(args: string[]): Promise<{ ok: true; stdout: string } | { ok: false; stderr: string }> {
|
|
172
|
+
return new Promise((resolvePromise) => {
|
|
173
|
+
const child = spawn('gh', ['issue', 'create', ...args], { env: buildAllowedEnv() })
|
|
174
|
+
let stdout = ''
|
|
175
|
+
let stderr = ''
|
|
176
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk) })
|
|
177
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk) })
|
|
178
|
+
child.on('error', (err) => {
|
|
179
|
+
resolvePromise({ ok: false, stderr: String(err) })
|
|
180
|
+
})
|
|
181
|
+
child.on('exit', (code) => {
|
|
182
|
+
if (code === 0) resolvePromise({ ok: true, stdout })
|
|
183
|
+
else resolvePromise({ ok: false, stderr })
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 仅供 TUI/CLI 本地进程调用——内部走 gh CLI 通道(spawn 本机已认证的 gh)。
|
|
190
|
+
* 不做通道选择编排(先试 gh、失败降级浏览器)——那是宿主 CLI 核心层职责(RFC-209 §2.4/
|
|
191
|
+
* 重要事项规则7),本函数只负责"尝试用 gh 提交,报告成功或分类后的失败"。
|
|
192
|
+
*/
|
|
193
|
+
export async function submitIssueViaGh(
|
|
194
|
+
input: FeedbackIssueInput,
|
|
195
|
+
logger: FeedbackLoggerPort = noopLogger,
|
|
196
|
+
): Promise<FeedbackIssueResult> {
|
|
197
|
+
// RFC-345:影子态零磁盘写入——gh 通道必须落临时 body 文件(§2.6a)才能跑,
|
|
198
|
+
// 无法不写盘完成,故整个 gh 通道在影子态下禁用;宿主收到本结果会走浏览器
|
|
199
|
+
// 通道(buildIssueUrl 纯函数、零磁盘写入,不受影响)。
|
|
200
|
+
if (isShadowModeEnabled()) {
|
|
201
|
+
return { ok: false, kind: 'shadow-mode-disabled', detail: 'gh issue 通道在影子模式下禁用(避免临时 body 文件落盘)' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const authed = await checkGhAuthStatus()
|
|
205
|
+
if (!authed) {
|
|
206
|
+
return { ok: false, kind: 'gh-not-authenticated', detail: 'gh CLI not found or not authenticated' }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const title = `[otto] ${input.title}`
|
|
210
|
+
const body = buildIssueBody(input)
|
|
211
|
+
const { path: bodyPath, cleanup } = writeTempBodyFile(body)
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
let result = await runGhIssueCreate([
|
|
215
|
+
'--repo', input.repo,
|
|
216
|
+
'--title', title,
|
|
217
|
+
'--body-file', bodyPath,
|
|
218
|
+
'--label', 'from-cli',
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
if (!result.ok && isLabelError(result.stderr)) {
|
|
222
|
+
// RFC-207 §2.7 R8:label 不存在时去掉 --label 重试一次。
|
|
223
|
+
result = await runGhIssueCreate([
|
|
224
|
+
'--repo', input.repo,
|
|
225
|
+
'--title', title,
|
|
226
|
+
'--body-file', bodyPath,
|
|
227
|
+
])
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (result.ok) {
|
|
231
|
+
const url = result.stdout.trim().split('\n').pop() ?? ''
|
|
232
|
+
return { ok: true, url }
|
|
233
|
+
}
|
|
234
|
+
const classified = classifyGhError(result.stderr)
|
|
235
|
+
logger.warn(`gh issue create failed: ${JSON.stringify(classified)}`)
|
|
236
|
+
return { ok: false, ...classified }
|
|
237
|
+
} finally {
|
|
238
|
+
cleanup()
|
|
239
|
+
process.on('exit', cleanup) // 兜底:异常退出(Ctrl+C)时仍尝试清理
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* platform-resolver.ts — RFC-430 §2.1:反馈面板目标平台判定链。
|
|
3
|
+
*
|
|
4
|
+
* 判定顺序(显式配置优先于自动探测):
|
|
5
|
+
* 1. settings `feedback_platform` 为 `'github'`/`'coding'` 显式值 → 直接生效,零探测;
|
|
6
|
+
* 2. `'auto'`(含未配置)→ `feedback_coding_base_url` 已配置且该地址经
|
|
7
|
+
* `isIntranetReachable(false, [baseUrl])`(@x-otto/shared,TCP 握手语义)可达
|
|
8
|
+
* → `'coding'`;
|
|
9
|
+
* 3. 其余一切情形(base_url 未配置/不可达/URL 非法)→ `'github'`。
|
|
10
|
+
*
|
|
11
|
+
* settings 读取经宿主注入端口(`FeedbackSettingsPort`,由 plugin.ts 的 ctx/host
|
|
12
|
+
* `getSettings` 装配注入),本模块不直读配置文件——保持插件的可测试性与宿主的
|
|
13
|
+
* 配置权威(单一真源在宿主 setting 层)。
|
|
14
|
+
*/
|
|
15
|
+
import { isIntranetReachable } from '@x-otto/shared'
|
|
16
|
+
|
|
17
|
+
import type { FeedbackSource } from '@x-otto/plugin'
|
|
18
|
+
import type { FeedbackLoggerPort } from './pulse-survey-sink'
|
|
19
|
+
|
|
20
|
+
/** 宿主注入的 settings 读取端口(窄接口,只暴露本插件需要的三个键)。 */
|
|
21
|
+
export interface FeedbackSettingsPort {
|
|
22
|
+
/** 等价 settings `feedback_repo`(`owner/repo`)。 */
|
|
23
|
+
getFeedbackRepo(): string | undefined
|
|
24
|
+
/** 等价 settings `feedback_platform`;未配置返回 undefined(语义 'auto')。 */
|
|
25
|
+
getFeedbackPlatform(): 'auto' | 'github' | 'coding' | undefined
|
|
26
|
+
/** 等价 settings `feedback_coding_base_url`。 */
|
|
27
|
+
getFeedbackCodingBaseUrl(): string | undefined
|
|
28
|
+
/** 等价 settings `feedback_coding_repo`。 */
|
|
29
|
+
getFeedbackCodingRepo(): string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 缺省空端口(单测/宿主未注入场景):全部 undefined —— 判定链恒落 'github'。 */
|
|
33
|
+
export const EMPTY_SETTINGS_PORT: FeedbackSettingsPort = {
|
|
34
|
+
getFeedbackRepo: () => undefined,
|
|
35
|
+
getFeedbackPlatform: () => undefined,
|
|
36
|
+
getFeedbackCodingBaseUrl: () => undefined,
|
|
37
|
+
getFeedbackCodingRepo: () => undefined,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const noopLogger: FeedbackLoggerPort = { warn: () => {} }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 平台判定链(见文件头)。baseUrl 以 `https?://` 开头才参与探测(非法配置静默落
|
|
44
|
+
* github + warn,不抛);探测结果按 host:port 缓存(isIntranetReachable 内部 TTL)。
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveFeedbackPlatform(
|
|
47
|
+
settings: FeedbackSettingsPort,
|
|
48
|
+
logger: FeedbackLoggerPort = noopLogger,
|
|
49
|
+
): Promise<FeedbackSource> {
|
|
50
|
+
let explicit: 'auto' | 'github' | 'coding' | undefined
|
|
51
|
+
try {
|
|
52
|
+
explicit = settings.getFeedbackPlatform()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
logger.warn(`feedback platform resolve: settings read failed (fail-soft → github): ${String(error)}`)
|
|
55
|
+
return 'github'
|
|
56
|
+
}
|
|
57
|
+
if (explicit === 'github' || explicit === 'coding') return explicit
|
|
58
|
+
|
|
59
|
+
// 'auto' / 未配置:仅当 base_url 已配置且内网可达时才选 coding。
|
|
60
|
+
let baseUrl: string | undefined
|
|
61
|
+
try {
|
|
62
|
+
baseUrl = settings.getFeedbackCodingBaseUrl()
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.warn(`feedback platform resolve: coding base url read failed (fail-soft → github): ${String(error)}`)
|
|
65
|
+
return 'github'
|
|
66
|
+
}
|
|
67
|
+
if (!baseUrl || !/^https?:\/\//.test(baseUrl)) return 'github'
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return (await isIntranetReachable(false, [baseUrl])) ? 'coding' : 'github'
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// 防御性兜底:isIntranetReachable 本身已 fail-soft,此处只为杜绝任何意外穿透。
|
|
73
|
+
logger.warn(`feedback platform resolve: intranet probe failed (fail-soft → github): ${String(error)}`)
|
|
74
|
+
return 'github'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pulse-survey-sink.ts — RFC-208 §2.2/§3.3:满意度脉冲调查本地日志落地层。
|
|
3
|
+
* RFC-209 §2.2/重要事项规则6:从 packages/coding/src/feedback/pulse-survey-sink.ts
|
|
4
|
+
* **原样迁移**(纯代码搬迁,非重写)——fail-soft/5MB截断/sink解析逻辑与迁移前完全一致。
|
|
5
|
+
*
|
|
6
|
+
* `PulseSurveySink` 是数据落地的可替换接口(RFC-208 grill 结果:"可配置,暂时留接口")——
|
|
7
|
+
* v1 只提供 `LocalJsonlPulseSurveySink` 一个实现,写入 `~/.otto/pulse-surveys.jsonl`,
|
|
8
|
+
* 不联网、不需要凭据。配置项 `pulse_survey.sink` 当前仅 `'local'` 合法(非法值 fallback,
|
|
9
|
+
* 见 `resolvePulseSurveySink`),不实现除本地 JSONL 外的任何 sink(RFC-208 重要事项规则 4)。
|
|
10
|
+
*
|
|
11
|
+
* 写入模式复用 `plugin-log-writer.ts` 的既有设计(同步 appendFileSync + 5MB 截断保留最后
|
|
12
|
+
* 50% + fail-soft 全局吞错)——本弹层触发频率远低于插件日志,性能非考量因素;两处独立各自
|
|
13
|
+
* 实现一份(而非抽取共享 helper),因为跨包共享收益不明显、且各自的 fail-soft 边界(本模块
|
|
14
|
+
* 不涉及 pluginId 路径穿越校验)不完全相同,重复优于错误的抽象。
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, appendFileSync, statSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { OTTO_HOME, isShadowModeEnabled } from '@x-otto/env'
|
|
20
|
+
|
|
21
|
+
import type { PulseSurveyEntry, PulseSurveyRating } from '@x-otto/plugin'
|
|
22
|
+
|
|
23
|
+
export type { PulseSurveyEntry, PulseSurveyRating }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 宿主注入的日志端口(RFC-185 D3:插件源码禁止自建 createLogger——esbuild 会把
|
|
27
|
+
* `@x-otto/shared` logger 整体 bundle 进 plugin.cjs 形成宿主管不着的第二份 pino 单例,
|
|
28
|
+
* trace 级直写 stderr 破坏 Ink TUI)。形状对齐 `PluginContext.logger` 的 warn 档;
|
|
29
|
+
* 缺省 no-op(如单测直接实例化、宿主未注入场景)。
|
|
30
|
+
*/
|
|
31
|
+
export interface FeedbackLoggerPort {
|
|
32
|
+
warn: (msg: string) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noopLogger: FeedbackLoggerPort = { warn: () => {} }
|
|
36
|
+
|
|
37
|
+
/** 单文件大小上限(5MB,同 plugin-log-writer.ts);超限截断保留最后 50%。 */
|
|
38
|
+
const MAX_LOG_FILE_BYTES = 5 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
/** 数据落地可替换接口(RFC-208 grill "留接口"要求)。v1 仅 `LocalJsonlPulseSurveySink` 一种实现。 */
|
|
41
|
+
export interface PulseSurveySink {
|
|
42
|
+
record(entry: PulseSurveyEntry): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 日志文件路径(`resolve` 而非 `join`,对齐 plugin-log-writer.ts:47 pluginLogsDir() 的既有惯例)。 */
|
|
46
|
+
export function pulseSurveyLogPath(): string {
|
|
47
|
+
return resolve(OTTO_HOME, 'pulse-surveys.jsonl')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 单文件超限时截断保留最后 50%(有损策略,同 plugin-log-writer.ts truncateIfOversized)。 */
|
|
51
|
+
function truncateIfOversized(path: string, logger: FeedbackLoggerPort): void {
|
|
52
|
+
let size: number
|
|
53
|
+
try {
|
|
54
|
+
size = statSync(path).size
|
|
55
|
+
} catch {
|
|
56
|
+
return // 文件不存在,无需截断
|
|
57
|
+
}
|
|
58
|
+
if (size <= MAX_LOG_FILE_BYTES) return
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const content = readFileSync(path, 'utf8')
|
|
62
|
+
const keepFrom = Math.floor(content.length / 2)
|
|
63
|
+
// 从字符中点找下一个换行符,避免截断到半行 JSON。
|
|
64
|
+
const nl = content.indexOf('\n', keepFrom)
|
|
65
|
+
const truncated = nl >= 0 ? content.slice(nl + 1) : content.slice(keepFrom)
|
|
66
|
+
writeFileSync(path, truncated)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// fail-soft:截断失败不影响后续写入尝试(append 会继续追加到超限文件,下次再试截断)。
|
|
69
|
+
logger.warn(`pulse survey log truncation failed (fail-soft): path=${path} err=${String(error)}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 本地 JSONL 落地实现(RFC-208 v1 唯一 sink)。
|
|
75
|
+
* fail-soft(RFC-208 重要事项规则 3):写入失败绝不能抛出到调用方或打断用户当前对话流,
|
|
76
|
+
* 全部异常在本类内部吞掉,仅 logger.warn 记录。
|
|
77
|
+
*/
|
|
78
|
+
export class LocalJsonlPulseSurveySink implements PulseSurveySink {
|
|
79
|
+
constructor(private readonly logger: FeedbackLoggerPort = noopLogger) {}
|
|
80
|
+
|
|
81
|
+
record(entry: PulseSurveyEntry): void {
|
|
82
|
+
// RFC-345 §D3:影子态不写脉冲调查日志(~/.otto/pulse-surveys.jsonl)。
|
|
83
|
+
if (isShadowModeEnabled()) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const path = pulseSurveyLogPath()
|
|
88
|
+
const dir = resolve(OTTO_HOME)
|
|
89
|
+
if (!existsSync(dir)) {
|
|
90
|
+
mkdirSync(dir, { recursive: true })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
truncateIfOversized(path, this.logger)
|
|
94
|
+
|
|
95
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.logger.warn(`pulse survey record append failed (fail-soft): ${String(error)}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `pulse_survey.sink` 配置解析(RFC-208 重要事项规则 4):当前仅 `'local'` 合法,
|
|
104
|
+
* 非法值 fallback 到 `'local'` 并 warn,不实现除本地 JSONL 外的任何 sink。
|
|
105
|
+
*/
|
|
106
|
+
export function resolvePulseSurveySink(
|
|
107
|
+
sinkConfig: string | undefined,
|
|
108
|
+
logger: FeedbackLoggerPort = noopLogger,
|
|
109
|
+
): PulseSurveySink {
|
|
110
|
+
if (sinkConfig !== undefined && sinkConfig !== 'local') {
|
|
111
|
+
logger.warn(`pulse_survey.sink="${sinkConfig}" is not a supported sink; falling back to 'local'`)
|
|
112
|
+
}
|
|
113
|
+
return new LocalJsonlPulseSurveySink(logger)
|
|
114
|
+
}
|