@quolu/lattice 0.75.0 → 0.76.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 CHANGED
@@ -185,8 +185,9 @@ await scheduleRuntimeErrorReport();
185
185
  * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
186
186
  */
187
187
  async function scheduleRuntimeErrorReport() {
188
+ // `reporting verify`の直後は送らない。受け口は1分に1回まで受けるので、続けて送ると断られる。
188
189
  if (help !== null || args[0] === '--version' || args[0] === 'hooks'
189
- || (args[0] === 'runtime-errors' && args[1] === 'report')) return;
190
+ || (args[0] === 'runtime-errors' && (args[1] === 'report' || (args[1] === 'reporting' && args[2] === 'verify')))) return;
190
191
  try {
191
192
  const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
192
193
  if (!runtimeErrorReportingEnabled()) return;
@@ -222,7 +223,7 @@ function workingDirectory() {
222
223
  async function runRuntimeErrorsCli(rest) {
223
224
  const runtimeErrors = await import('../src/runtime-errors.mjs');
224
225
  const usage = () => {
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`);
226
+ 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|verify>> --json' })}\n`);
226
227
  return 2;
227
228
  };
228
229
  const options = { version: packageJson.version };
@@ -251,6 +252,12 @@ async function runRuntimeErrorsCli(rest) {
251
252
  result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
252
253
  } else if (words[0] === 'compact' && words.length === 1) {
253
254
  result = runtimeErrors.compactRuntimeErrors(options);
255
+ } else if (words[0] === 'reporting' && words.length === 2 && words[1] === 'verify') {
256
+ // 空の報告を1通送って経路を確かめる。受領まで確かめられた時だけexit 0。
257
+ const reporting = await import('../src/runtime-error-reporting.mjs');
258
+ result = await reporting.verifyRuntimeErrorReporting(options);
259
+ process.stdout.write(`${JSON.stringify(result)}\n`);
260
+ return result.outcome === 'verified' ? 0 : 1;
254
261
  } else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
255
262
  const reporting = await import('../src/runtime-error-reporting.mjs');
256
263
  result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.75.0",
3
+ "version": "0.76.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
@@ -198,6 +198,7 @@ Commands:
198
198
  compact
199
199
  report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
200
200
  reporting <status|enable|disable>
201
+ reporting verify 空の報告を1通送り、合鍵と受け口までの経路を確かめる(記録には触れない)
201
202
  `,
202
203
  bridge: `Usage: lattice bridge <command> [options] --json
203
204
 
@@ -315,7 +316,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
315
316
  'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
316
317
  'runtime-errors compact': 'runtime-errors compact --json',
317
318
  'runtime-errors report': 'runtime-errors report --json',
318
- 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
319
+ 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable|verify> --json',
319
320
  'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
320
321
  'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
321
322
  'bridge status': 'bridge status --json',
@@ -31,6 +31,8 @@ import {
31
31
  * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
32
32
  * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
33
33
  * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
34
+ * - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。
35
+ * 受け口は受領を記録するだけで、issueも通知も動かない。storeの記録と受領の印には触れない。
34
36
  */
35
37
 
36
38
  const REPORT_SCHEMA_VERSION = '1.0';
@@ -275,6 +277,41 @@ function parseJson(text) {
275
277
  }
276
278
  }
277
279
 
280
+ /**
281
+ * 報告を1通、署名して送り、受領を確かめる。`accepted`を返すのは、200・`accepted: true`・`report_id`一致・
282
+ * 応答の署名一致がそろった時だけ。戻り値に秘密・key_id・宛先は入れない。
283
+ */
284
+ async function sendReport({ url, keyId, secret }, snapshot, options) {
285
+ const now = nowMs(options);
286
+ const ts = Math.floor(now / 1000);
287
+ // `observed_at`は、載せる記録のどの時刻よりも前にしない(BugHubは観測より後の記録を断る)。秒へ切り捨てると、
288
+ // 記録と同じ1秒の中で送った時に記録より前へずれる。`ts`とは同じ時刻から作り、差はBugHubの許す幅(10分)に収まる。
289
+ const observed = Math.max(now, ...snapshot.runtime_errors.map((record) => Date.parse(record.last_seen)),
290
+ ...snapshot.resolutions.map((resolution) => Date.parse(resolution.resolved_at)));
291
+ const report = buildRuntimeErrorReport({ snapshot, version: options.version ?? 'unknown',
292
+ reportId: options.reportId ?? randomUUID(), observedAt: new Date(observed).toISOString() });
293
+ const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
294
+ if (bodyBytes.length > REPORT_MAX_BYTES) return { outcome: 'too_large', reason: 'report_too_large', http_status: null };
295
+
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 { outcome: 'unconfirmed', reason: response.reason, http_status: null };
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 { outcome: 'unconfirmed', reason: 'receipt_not_verified', http_status: 200 };
308
+ }
309
+ return { outcome: 'accepted', reason: null, http_status: 200 };
310
+ }
311
+ const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
312
+ return { outcome: response.status >= 500 ? 'unconfirmed' : 'rejected', reason: code, http_status: response.status };
313
+ }
314
+
278
315
  /**
279
316
  * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
280
317
  * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
@@ -305,13 +342,6 @@ export async function reportRuntimeErrors(options = {}) {
305
342
  if (reason !== null) return result('throttled', { reason });
306
343
  }
307
344
 
308
- // `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
309
- const ts = Math.floor(nowMs(options) / 1000);
310
- const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
311
- reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
312
- const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
313
- if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
314
-
315
345
  const finish = (outcome, extra = {}) => {
316
346
  try {
317
347
  writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
@@ -320,31 +350,33 @@ export async function reportRuntimeErrors(options = {}) {
320
350
  return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
321
351
  };
322
352
 
323
- const { credential: { url, keyId, secret } } = credential;
324
- const response = await post(url, {
325
- 'Content-Type': 'application/json',
326
- 'Content-Length': String(bodyBytes.length),
327
- Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
328
- }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
329
-
330
- if (response.status === null) return finish('unconfirmed', { reason: response.reason });
331
- const body = parseJson(response.body);
332
- if (response.status === 200) {
333
- if (body === null || body.accepted !== true || body.report_id !== report.report_id
334
- || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
335
- return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
336
- }
337
- try {
338
- const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
339
- return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
340
- } catch {
341
- // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
342
- return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
343
- }
353
+ const answer = await sendReport(credential.credential, pending, options);
354
+ if (answer.outcome === 'too_large') return result('rejected', { reason: 'report_too_large', sent });
355
+ if (answer.outcome !== 'accepted') return finish(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
356
+ try {
357
+ const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
358
+ return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
359
+ } catch {
360
+ // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
361
+ return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
344
362
  }
345
- const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
346
- if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status });
347
- return finish('rejected', { reason: code, http_status: response.status });
363
+ }
364
+
365
+ /**
366
+ * 記録を載せない空の報告を1通送り、合鍵・署名・受け口までの経路を確かめる(`reporting verify`)。
367
+ * 送る条件は`report`と同じで、送信を有効にしていない端末と合鍵の無い端末はnetworkへ触れない。
368
+ * storeの記録・受領の印・送る時機の記録には触れない。受け口は端末×製品ごとに1分に1回まで受けるので、
369
+ * 直前に送っていれば429で断られる。
370
+ */
371
+ export async function verifyRuntimeErrorReporting(options = {}) {
372
+ if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
373
+ if (!runtimeErrorReportingEnabled(options)) return result('disabled');
374
+ const credential = readProductCredential(options);
375
+ if (credential.status === 'missing') return result('credential_missing');
376
+ if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
377
+ const answer = await sendReport(credential.credential, { runtime_errors: [], resolutions: [] }, options);
378
+ if (answer.outcome === 'accepted') return result('verified', { http_status: 200 });
379
+ return result(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
348
380
  }
349
381
 
350
382
  /**