@quolu/lattice 0.73.0 → 0.74.0
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/bin/lattice.mjs +34 -1
- package/package.json +1 -1
- package/src/cli-help.mjs +4 -0
- package/src/runtime-error-reporting.mjs +336 -0
- package/src/runtime-errors.mjs +41 -6
package/bin/lattice.mjs
CHANGED
|
@@ -177,6 +177,29 @@ if (help !== null) {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
await pendingObservation;
|
|
181
|
+
await scheduleRuntimeErrorReport();
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 未受領のruntime error記録を、切り離した子processで送らせる(ADR 0193)。このCLIの応答は待たせない。
|
|
185
|
+
* 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
|
|
186
|
+
*/
|
|
187
|
+
async function scheduleRuntimeErrorReport() {
|
|
188
|
+
if (help !== null || args[0] === '--version' || args[0] === 'hooks'
|
|
189
|
+
|| (args[0] === 'runtime-errors' && args[1] === 'report')) return;
|
|
190
|
+
try {
|
|
191
|
+
const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
|
|
192
|
+
if (!runtimeErrorReportingEnabled()) return;
|
|
193
|
+
const { runtimeErrorAutoReportDue } = await import('../src/runtime-error-reporting.mjs');
|
|
194
|
+
if (!runtimeErrorAutoReportDue()) return;
|
|
195
|
+
const { spawn } = await import('node:child_process');
|
|
196
|
+
const { tmpdir } = await import('node:os');
|
|
197
|
+
const { fileURLToPath } = await import('node:url');
|
|
198
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), 'runtime-errors', 'report', '--auto', '--json'],
|
|
199
|
+
{ cwd: tmpdir(), detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
200
|
+
} catch { /* 送信の予約に失敗しても、打たれたcommandの結果は変えない。 */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
// typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
|
|
181
204
|
// 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
|
|
182
205
|
function observeInternalFailure(error, commandKind) {
|
|
@@ -199,7 +222,7 @@ function workingDirectory() {
|
|
|
199
222
|
async function runRuntimeErrorsCli(rest) {
|
|
200
223
|
const runtimeErrors = await import('../src/runtime-errors.mjs');
|
|
201
224
|
const usage = () => {
|
|
202
|
-
process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact
|
|
225
|
+
process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable>> --json' })}\n`);
|
|
203
226
|
return 2;
|
|
204
227
|
};
|
|
205
228
|
const options = { version: packageJson.version };
|
|
@@ -228,6 +251,16 @@ async function runRuntimeErrorsCli(rest) {
|
|
|
228
251
|
result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
|
|
229
252
|
} else if (words[0] === 'compact' && words.length === 1) {
|
|
230
253
|
result = runtimeErrors.compactRuntimeErrors(options);
|
|
254
|
+
} else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
|
|
255
|
+
const reporting = await import('../src/runtime-error-reporting.mjs');
|
|
256
|
+
result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
|
|
257
|
+
: reporting.setRuntimeErrorReporting(words[1] === 'enable', options);
|
|
258
|
+
} else if (words[0] === 'report' && (words.length === 1 || (words.length === 2 && words[1] === '--auto'))) {
|
|
259
|
+
// 送れなかった時も結果は1行で返す。受領まで済んだ時と、送るものが無い時だけexit 0。
|
|
260
|
+
const reporting = await import('../src/runtime-error-reporting.mjs');
|
|
261
|
+
result = await reporting.reportRuntimeErrors({ ...options, auto: words.length === 2 });
|
|
262
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
263
|
+
return ['delivered', 'nothing_pending'].includes(result.outcome) ? 0 : 1;
|
|
231
264
|
} else {
|
|
232
265
|
return usage();
|
|
233
266
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
package/src/cli-help.mjs
CHANGED
|
@@ -196,6 +196,8 @@ Commands:
|
|
|
196
196
|
resolve <fingerprint>
|
|
197
197
|
reopen <fingerprint>
|
|
198
198
|
compact
|
|
199
|
+
report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
|
|
200
|
+
reporting <status|enable|disable>
|
|
199
201
|
`,
|
|
200
202
|
bridge: `Usage: lattice bridge <command> [options] --json
|
|
201
203
|
|
|
@@ -312,6 +314,8 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
312
314
|
'runtime-errors resolve': 'runtime-errors resolve <fingerprint> --json',
|
|
313
315
|
'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
|
|
314
316
|
'runtime-errors compact': 'runtime-errors compact --json',
|
|
317
|
+
'runtime-errors report': 'runtime-errors report --json',
|
|
318
|
+
'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
|
|
315
319
|
'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
|
|
316
320
|
'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
|
|
317
321
|
'bridge status': 'bridge status --json',
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import https from 'node:https';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
REPORTING_CONFIG_SCHEMA,
|
|
12
|
+
acknowledgeRuntimeErrors,
|
|
13
|
+
ensureSafeDir,
|
|
14
|
+
ensureSafeFile,
|
|
15
|
+
runtimeErrorCollectionSupported,
|
|
16
|
+
runtimeErrorReportingConfigPath,
|
|
17
|
+
runtimeErrorReportingEnabled,
|
|
18
|
+
runtimeErrorsDiagnostics,
|
|
19
|
+
runtimeErrorsSnapshot,
|
|
20
|
+
runtimeErrorsStatePath,
|
|
21
|
+
} from './runtime-errors.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* runtime error記録を、Lattice自身がBugHubの製品報告の受け口へ送る(ADR 0193)。
|
|
25
|
+
*
|
|
26
|
+
* - 既定では通信しない。端末で`runtime-errors reporting enable`を打ち、BugHubの持ち主が合鍵のfileを
|
|
27
|
+
* 置いた端末だけが送る。どちらかが欠ければ、networkへ触れない。
|
|
28
|
+
* - 秘密は通信に載せない。本文のSHA-256と時刻へのHMAC-SHA256署名だけを`Authorization`に付ける。
|
|
29
|
+
* - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
|
|
30
|
+
* そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
|
|
31
|
+
* - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const REPORT_SCHEMA_VERSION = '1.0';
|
|
35
|
+
const RESULT_SCHEMA = 'lattice.runtime_error_report_result.v1';
|
|
36
|
+
const STATUS_SCHEMA = 'lattice.runtime_error_reporting_status.v1';
|
|
37
|
+
const DELIVERY_SCHEMA = 'lattice.runtime_error_delivery.v1';
|
|
38
|
+
const PRODUCT_ID = 'lattice';
|
|
39
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
40
|
+
const RESPONSE_MAX_BYTES = 64 * 1024;
|
|
41
|
+
const REPORT_MAX_BYTES = 512 * 1024;
|
|
42
|
+
const REPORT_MAX_RECORDS = 256;
|
|
43
|
+
// BugHubは端末×製品ごとに1分に1回まで受ける。届かなかった同じ中身の送り直しは1時間に1回まで。
|
|
44
|
+
const MIN_ATTEMPT_INTERVAL_MS = 60_000;
|
|
45
|
+
const RETRY_INTERVAL_MS = 60 * 60_000;
|
|
46
|
+
const KEY_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
47
|
+
const REJECTION_CODE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
48
|
+
const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'unsupported', 'credential_missing',
|
|
49
|
+
'credential_unsafe', 'throttled', 'rejected', 'unconfirmed', 'store_unavailable']);
|
|
50
|
+
|
|
51
|
+
const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
|
+
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
53
|
+
|
|
54
|
+
export function productCredentialPath(env = process.env) {
|
|
55
|
+
return join(env.HOME || homedir(), '.config', 'bughub', 'product-credentials', `${PRODUCT_ID}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 合鍵のfileを読む。BugHubの持ち主が置く形(本人所有・0600・symlinkでない)以外は使わない。
|
|
60
|
+
* 秘密は戻り値の中だけに留め、結果や記録へ写さない。
|
|
61
|
+
*/
|
|
62
|
+
export function readProductCredential(options = {}) {
|
|
63
|
+
const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env);
|
|
64
|
+
let stats;
|
|
65
|
+
try {
|
|
66
|
+
stats = lstatSync(path);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' };
|
|
69
|
+
}
|
|
70
|
+
if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' };
|
|
71
|
+
if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
|
|
72
|
+
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
|
|
73
|
+
let value;
|
|
74
|
+
try {
|
|
75
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
76
|
+
} catch {
|
|
77
|
+
return { status: 'unsafe', reason: 'not_json' };
|
|
78
|
+
}
|
|
79
|
+
if (!plain(value) || !exact(value, ['url', 'key_id', 'secret'])
|
|
80
|
+
|| typeof value.url !== 'string' || typeof value.key_id !== 'string' || typeof value.secret !== 'string'
|
|
81
|
+
|| !KEY_ID.test(value.key_id) || value.secret.length < 16 || value.secret.length > 1024) {
|
|
82
|
+
return { status: 'unsafe', reason: 'invalid_shape' };
|
|
83
|
+
}
|
|
84
|
+
let url;
|
|
85
|
+
try {
|
|
86
|
+
url = new URL(value.url);
|
|
87
|
+
} catch {
|
|
88
|
+
return { status: 'unsafe', reason: 'invalid_url' };
|
|
89
|
+
}
|
|
90
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username !== '' || url.password !== '') {
|
|
91
|
+
return { status: 'unsafe', reason: 'invalid_url' };
|
|
92
|
+
}
|
|
93
|
+
return { status: 'ok', credential: { url, keyId: value.key_id, secret: value.secret } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
97
|
+
const hmacHex = (secret, text) => createHmac('sha256', Buffer.from(secret, 'utf8')).update(text, 'utf8').digest('hex');
|
|
98
|
+
|
|
99
|
+
/** `sig = HMAC-SHA256(secret, ts + "\n" + SHA-256(送るバイト列の16進))`。秘密は文字列のままUTF-8で鍵にする。 */
|
|
100
|
+
export function signReport(secret, ts, bodyBytes) {
|
|
101
|
+
return hmacHex(secret, `${ts}\n${sha256Hex(bodyBytes)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 応答の署名: `HMAC-SHA256(secret, report_id + "\n" + received_at)`。別の機器が返した200を受領済みにしない。 */
|
|
105
|
+
export function receiptSignatureMatches(secret, reportId, receivedAt, sig) {
|
|
106
|
+
if (typeof receivedAt !== 'string' || typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false;
|
|
107
|
+
const expected = Buffer.from(hmacHex(secret, `${reportId}\n${receivedAt}`), 'hex');
|
|
108
|
+
return timingSafeEqual(expected, Buffer.from(sig, 'hex'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** snapshotの未受領分から、受け口へ送る本文を組む。項目はsnapshotが出すものをそのまま載せる。 */
|
|
112
|
+
export function buildRuntimeErrorReport({ snapshot, version, reportId, observedAt }) {
|
|
113
|
+
return {
|
|
114
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
115
|
+
report_id: reportId,
|
|
116
|
+
product_id: PRODUCT_ID,
|
|
117
|
+
installed_version: version,
|
|
118
|
+
observed_at: observedAt,
|
|
119
|
+
runtime_errors: snapshot.runtime_errors,
|
|
120
|
+
resolutions: snapshot.resolutions,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function deliveryStatePath(options) {
|
|
125
|
+
const env = options.env ?? process.env;
|
|
126
|
+
return join(dirname(options.storePath ?? runtimeErrorsStatePath(env)), 'runtime-errors-delivery.json');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const emptyDelivery = () => ({ schema: DELIVERY_SCHEMA, last_attempt_at: null, last_outcome: null, attempted_through: 0 });
|
|
130
|
+
|
|
131
|
+
function readDelivery(options) {
|
|
132
|
+
const path = deliveryStatePath(options);
|
|
133
|
+
if (!existsSync(path)) return emptyDelivery();
|
|
134
|
+
try {
|
|
135
|
+
ensureSafeFile(path);
|
|
136
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
137
|
+
if (!plain(value) || !exact(value, ['schema', 'last_attempt_at', 'last_outcome', 'attempted_through'])
|
|
138
|
+
|| value.schema !== DELIVERY_SCHEMA
|
|
139
|
+
|| (value.last_attempt_at !== null && !Number.isFinite(Date.parse(value.last_attempt_at)))
|
|
140
|
+
|| (value.last_outcome !== null && !OUTCOMES.includes(value.last_outcome))
|
|
141
|
+
|| !Number.isSafeInteger(value.attempted_through) || value.attempted_through < 0) return emptyDelivery();
|
|
142
|
+
return value;
|
|
143
|
+
} catch {
|
|
144
|
+
return emptyDelivery();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writeDelivery(options, state) {
|
|
149
|
+
const path = deliveryStatePath(options);
|
|
150
|
+
ensureSafeDir(dirname(path));
|
|
151
|
+
const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' });
|
|
154
|
+
renameSync(temporary, path);
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(temporary, { force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const nowMs = (options) => (options.now ? new Date(options.now).valueOf() : Date.now());
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 自動送信してよい時機か。故障を記録した直後・解決を打った直後・以後のCLI実行が呼ぶ。
|
|
164
|
+
* 未受領が無い、直前の試みから1分未満、同じ中身の送り直しが1時間未満、のどれかなら送らない。
|
|
165
|
+
*/
|
|
166
|
+
function throttleReason(options, delivery, highWatermark) {
|
|
167
|
+
if (delivery.last_attempt_at === null) return null;
|
|
168
|
+
const elapsed = nowMs(options) - Date.parse(delivery.last_attempt_at);
|
|
169
|
+
if (elapsed < MIN_ATTEMPT_INTERVAL_MS) return 'attempted_within_a_minute';
|
|
170
|
+
if (delivery.last_outcome !== 'delivered' && delivery.attempted_through === highWatermark
|
|
171
|
+
&& elapsed < RETRY_INTERVAL_MS) return 'same_report_retried_within_an_hour';
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function runtimeErrorReportingStatus(options = {}) {
|
|
176
|
+
const supported = runtimeErrorCollectionSupported(options);
|
|
177
|
+
const enabled = runtimeErrorReportingEnabled(options);
|
|
178
|
+
const credential = supported ? readProductCredential(options) : { status: 'missing' };
|
|
179
|
+
const diagnostics = runtimeErrorsDiagnostics(options);
|
|
180
|
+
const delivery = supported ? readDelivery(options) : emptyDelivery();
|
|
181
|
+
return {
|
|
182
|
+
schema: STATUS_SCHEMA,
|
|
183
|
+
reporting: !supported ? 'unsupported' : enabled ? 'enabled' : 'disabled',
|
|
184
|
+
credential: credential.status === 'ok' ? 'present' : credential.status,
|
|
185
|
+
credential_reason: credential.status === 'unsafe' ? credential.reason : null,
|
|
186
|
+
collection: diagnostics.collection,
|
|
187
|
+
store_status: diagnostics.status,
|
|
188
|
+
pending_count: diagnostics.pending_count,
|
|
189
|
+
last_attempt_at: delivery.last_attempt_at,
|
|
190
|
+
last_outcome: delivery.last_outcome,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 送信の設定をLattice自身のfileへ書く。dotagentsの設定は読みも書きもしない。 */
|
|
195
|
+
export function setRuntimeErrorReporting(enabled, options = {}) {
|
|
196
|
+
if (typeof enabled !== 'boolean') throw Error('invalid_enabled');
|
|
197
|
+
if (!runtimeErrorCollectionSupported(options)) throw Error('reporting_unsupported');
|
|
198
|
+
const env = options.env ?? process.env;
|
|
199
|
+
const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env);
|
|
200
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
201
|
+
try {
|
|
202
|
+
if (lstatSync(path).isSymbolicLink()) throw Error('config_unsafe');
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
205
|
+
}
|
|
206
|
+
const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
207
|
+
try {
|
|
208
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' });
|
|
209
|
+
renameSync(temporary, path);
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(temporary, { force: true });
|
|
212
|
+
}
|
|
213
|
+
return runtimeErrorReportingStatus(options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function post(url, headers, bodyBytes, timeoutMs) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
219
|
+
const request = client.request(url, { method: 'POST', headers, timeout: timeoutMs }, (response) => {
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let size = 0;
|
|
222
|
+
response.on('data', (chunk) => {
|
|
223
|
+
size += chunk.length;
|
|
224
|
+
if (size > RESPONSE_MAX_BYTES) { request.destroy(); resolve({ status: null, reason: 'response_too_large' }); return; }
|
|
225
|
+
chunks.push(chunk);
|
|
226
|
+
});
|
|
227
|
+
response.on('end', () => resolve({ status: response.statusCode ?? null, body: Buffer.concat(chunks).toString('utf8') }));
|
|
228
|
+
response.on('error', () => resolve({ status: null, reason: 'response_error' }));
|
|
229
|
+
});
|
|
230
|
+
request.on('timeout', () => { request.destroy(); resolve({ status: null, reason: 'timeout' }); });
|
|
231
|
+
request.on('error', () => resolve({ status: null, reason: 'network_error' }));
|
|
232
|
+
request.end(bodyBytes);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = (outcome, extra = {}) => ({
|
|
237
|
+
schema: RESULT_SCHEMA, outcome, reason: null, http_status: null,
|
|
238
|
+
sent: { runtime_errors: 0, resolutions: 0 }, acknowledged_through: null, ...extra,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function parseJson(text) {
|
|
242
|
+
try {
|
|
243
|
+
const value = JSON.parse(text);
|
|
244
|
+
return plain(value) ? value : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
|
|
252
|
+
* ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
|
|
253
|
+
*/
|
|
254
|
+
export async function reportRuntimeErrors(options = {}) {
|
|
255
|
+
if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
|
|
256
|
+
if (!runtimeErrorReportingEnabled(options)) return result('disabled');
|
|
257
|
+
const credential = readProductCredential(options);
|
|
258
|
+
if (credential.status === 'missing') return result('credential_missing');
|
|
259
|
+
if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
|
|
260
|
+
|
|
261
|
+
let pending;
|
|
262
|
+
let delivery;
|
|
263
|
+
try {
|
|
264
|
+
const diagnostics = runtimeErrorsDiagnostics(options);
|
|
265
|
+
if (diagnostics.status !== 'ready') return result('store_unavailable');
|
|
266
|
+
pending = runtimeErrorsSnapshot(diagnostics.acknowledged_through, REPORT_MAX_RECORDS, options);
|
|
267
|
+
delivery = readDelivery(options);
|
|
268
|
+
} catch {
|
|
269
|
+
return result('store_unavailable');
|
|
270
|
+
}
|
|
271
|
+
const sent = { runtime_errors: pending.runtime_errors.length, resolutions: pending.resolutions.length };
|
|
272
|
+
if (sent.runtime_errors + sent.resolutions === 0) {
|
|
273
|
+
return result('nothing_pending', { acknowledged_through: pending.cursor.acknowledged_through });
|
|
274
|
+
}
|
|
275
|
+
if (options.auto === true) {
|
|
276
|
+
const reason = throttleReason(options, delivery, pending.cursor.high_watermark);
|
|
277
|
+
if (reason !== null) return result('throttled', { reason });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
|
|
281
|
+
const ts = Math.floor(nowMs(options) / 1000);
|
|
282
|
+
const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
|
|
283
|
+
reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
|
|
284
|
+
const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
|
|
285
|
+
if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
|
|
286
|
+
|
|
287
|
+
const finish = (outcome, extra = {}) => {
|
|
288
|
+
try {
|
|
289
|
+
writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
|
|
290
|
+
last_outcome: outcome, attempted_through: pending.cursor.high_watermark });
|
|
291
|
+
} catch { /* 送信の結果は返す。時機の記録だけが残らない。 */ }
|
|
292
|
+
return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const { credential: { url, keyId, secret } } = credential;
|
|
296
|
+
const response = await post(url, {
|
|
297
|
+
'Content-Type': 'application/json',
|
|
298
|
+
'Content-Length': String(bodyBytes.length),
|
|
299
|
+
Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
|
|
300
|
+
}, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
301
|
+
|
|
302
|
+
if (response.status === null) return finish('unconfirmed', { reason: response.reason });
|
|
303
|
+
const body = parseJson(response.body);
|
|
304
|
+
if (response.status === 200) {
|
|
305
|
+
if (body === null || body.accepted !== true || body.report_id !== report.report_id
|
|
306
|
+
|| !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
|
|
307
|
+
return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
|
|
311
|
+
return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
|
|
312
|
+
} catch {
|
|
313
|
+
// 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
|
|
314
|
+
return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
|
|
318
|
+
if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status });
|
|
319
|
+
return finish('rejected', { reason: code, http_status: response.status });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、
|
|
324
|
+
* 設定fileの有無を1回見るだけで終わる。
|
|
325
|
+
*/
|
|
326
|
+
export function runtimeErrorAutoReportDue(options = {}) {
|
|
327
|
+
if (!runtimeErrorReportingEnabled(options)) return false;
|
|
328
|
+
if (readProductCredential(options).status !== 'ok') return false;
|
|
329
|
+
try {
|
|
330
|
+
const diagnostics = runtimeErrorsDiagnostics(options);
|
|
331
|
+
if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false;
|
|
332
|
+
return throttleReason(options, readDelivery(options), diagnostics.high_watermark) === null;
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/runtime-errors.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import { dirname, join } from 'node:path';
|
|
|
8
8
|
/**
|
|
9
9
|
* opt-in runtime error store(親plan L6要件。Caveat `caveat.runtime_errors.v1` と同型の工場契約)。
|
|
10
10
|
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* reporting
|
|
14
|
-
* - 既定OFF:
|
|
11
|
+
* - 収集の有効化: 工場共有config `${XDG_CONFIG_HOME:-~/.config}/dotagents/factory-reporter.json` の
|
|
12
|
+
* `collection.enabled`、またはLattice自身の送信設定(`runtime-error-reporting.json`、ADR 0193)のどちらかが
|
|
13
|
+
* 有効な時だけ収集する。本storeは外部送信を行わない——送信は`runtime-error-reporting.mjs`が持つ。
|
|
14
|
+
* - 既定OFF: どちらの設定も無い・malformed・disabledでは state も network も触らない。
|
|
15
15
|
* - privacy by design: 保存するのは固定catalogの `error_code` / `message_template` のみ。
|
|
16
16
|
* 生message・path・引数を保存しない。
|
|
17
17
|
* - retention: fingerprint集約(同一原因はcount/last_seen更新)+ack済みresolvedの30日compact。
|
|
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
const RUNTIME_ERRORS_SCHEMA = 'lattice.runtime_errors.v1';
|
|
23
23
|
const DIAGNOSTICS_SCHEMA = 'lattice.runtime_error_diagnostics.v1';
|
|
24
|
+
export const REPORTING_CONFIG_SCHEMA = 'lattice.runtime_error_reporting_config.v1';
|
|
24
25
|
const PRODUCT = 'lattice';
|
|
25
26
|
const STATE_VERSION = '1.0';
|
|
26
27
|
const MAX_RECORDS = 256;
|
|
@@ -82,7 +83,41 @@ function canonicalReporting(value) {
|
|
|
82
83
|
const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32';
|
|
83
84
|
const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported');
|
|
84
85
|
|
|
86
|
+
export function runtimeErrorCollectionSupported(options = {}) {
|
|
87
|
+
return collectionSupported(options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function runtimeErrorReportingConfigPath(env = process.env) {
|
|
91
|
+
const home = env.HOME || homedir();
|
|
92
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'lattice', 'runtime-error-reporting.json');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Lattice自身の送信設定(ADR 0193)。端末で`runtime-errors reporting enable`を打った時だけ有効になる。
|
|
97
|
+
* `LATTICE_RUNTIME_ERROR_REPORTING=0`は既定の置き場を読まない——試験と自動化が、その端末の本物の設定を
|
|
98
|
+
* 拾って送信しないための口。`options.reportingConfigPath`を明示した呼び出しはそのfileを読む。
|
|
99
|
+
*/
|
|
100
|
+
export function runtimeErrorReportingEnabled(options = {}) {
|
|
101
|
+
if (!collectionSupported(options)) return false;
|
|
102
|
+
const env = options.env ?? process.env;
|
|
103
|
+
if (options.reportingConfigPath === undefined && env.LATTICE_RUNTIME_ERROR_REPORTING === '0') return false;
|
|
104
|
+
try {
|
|
105
|
+
const path = options.reportingConfigPath ?? runtimeErrorReportingConfigPath(env);
|
|
106
|
+
const stats = lstatSync(path);
|
|
107
|
+
if (!stats.isFile() || stats.isSymbolicLink()) return false;
|
|
108
|
+
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
109
|
+
return plain(config) && exact(config, ['schema', 'enabled'])
|
|
110
|
+
&& config.schema === REPORTING_CONFIG_SCHEMA && config.enabled === true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
function collectionEnabled(options = {}) {
|
|
117
|
+
return factoryCollectionEnabled(options) || runtimeErrorReportingEnabled(options);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function factoryCollectionEnabled(options = {}) {
|
|
86
121
|
if (!collectionSupported(options)) return false;
|
|
87
122
|
const env = options.env ?? process.env;
|
|
88
123
|
try {
|
|
@@ -140,7 +175,7 @@ function assertPosix(info, mode) {
|
|
|
140
175
|
if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe');
|
|
141
176
|
}
|
|
142
177
|
|
|
143
|
-
function ensureSafeDir(dir) {
|
|
178
|
+
export function ensureSafeDir(dir) {
|
|
144
179
|
if (hostPlatform() === 'win32') throw Error('store_unsafe');
|
|
145
180
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
146
181
|
const stats = lstatSync(dir);
|
|
@@ -148,7 +183,7 @@ function ensureSafeDir(dir) {
|
|
|
148
183
|
assertPosix(stats, 0o700);
|
|
149
184
|
}
|
|
150
185
|
|
|
151
|
-
function ensureSafeFile(path) {
|
|
186
|
+
export function ensureSafeFile(path) {
|
|
152
187
|
const stats = lstatSync(path);
|
|
153
188
|
if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe');
|
|
154
189
|
assertPosix(statSync(path), 0o600);
|