@takagaki/cortex-decisions-viewer 0.12.53 → 0.12.55

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.
Files changed (2) hide show
  1. package/dist/render.js +236 -113
  2. package/package.json +2 -2
package/dist/render.js CHANGED
@@ -2169,19 +2169,17 @@ function calendarStateRow(rows) {
2169
2169
  * 接続状態ストアの行から、案件カレンダーの ID を引く(#428・#452)。
2170
2170
  *
2171
2171
  * 🔴 **ID は「最新の行」ではなく「ID を持つ最新の行」から引く。** 状態を運ぶ行
2172
- * (`calendarStateRow`)と別に引く理由は「共有を更新」——押すと裏の経路が番兵
2173
- * (`ref = "calendar"`)の `requested` を書き、それが最新になる。最新の1行だけから
2174
- * ID を引くと、**既にあるカレンダーのリンクとコピー欄が最長20分ぶん画面から消え**、
2175
- * さらに待つと「作成が終わっていません」まで出る(既にあるカレンダーについて)。
2172
+ * (`calendarStateRow`)と別に引く理由は、**カレンダーが出来ている案件にも
2173
+ * `requested` の行が立つ**から——受け口(`?op=calendar-request`)は共有の是正にも
2174
+ * 使われ、裏の経路が番兵(`ref = "calendar"`)の `requested` を書いてそれが最新になる。
2175
+ * 最新の1行だけから ID を引くと、**既にあるカレンダーのリンクとコピー欄が
2176
+ * 最長20分ぶん画面から消え**、さらに待つと「作成が終わっていません」まで出る。
2176
2177
  * **一度できたカレンダーは消えない**ので、ID は落とさないのが正しい。
2177
2178
  *
2178
- * 🔴 **「ストア側に ID を持つ行があるか」の判定を、ここに1本だけ置く(#452)。**
2179
- * これは `calendarPhase` が**「作成の依頼が処理中」と「共有の更新が処理中」を分ける
2180
- * 唯一の材料**でもある——依頼はサーバに飛ぶ 1 本きりで、`status` はどちらも
2181
- * `requested` だから、`status` だけでは種別が分からない。分かれるのは `ref` で、
2182
- * intake は**カレンダーが出来ている案件の依頼にだけ ID を載せる**(#427)。
2183
- * 判定を2枚に割ると片方だけ直り、**一括作成の直後に「共有を更新しています」**が
2184
- * 出る形へ静かに戻る。
2179
+ * かつては `calendarPhase` が**「作成の依頼が処理中」と「共有の更新が処理中」を
2180
+ * 分ける唯一の材料**としてもこれを呼んでいた(#452)。その出し分けは
2181
+ * **#544 で画面から「共有を更新」を外したときに無くなった**(出す先が消えたため)。
2182
+ * ここが残っているのは ID を落とさないためだけ。
2185
2183
  *
2186
2184
  * **クロージャを持たない純関数に保つこと**(`.toString()` でクライアントへ渡す)。
2187
2185
  */
@@ -2267,8 +2265,8 @@ function calendarRowCount(raw) {
2267
2265
  * - `none` … まだ無い(行が 1 本も無い)→ 「この案件のカレンダーを作る」
2268
2266
  * - `pending` … 依頼が立っている → 「作成中(最長 N 分)」
2269
2267
  * - `stale` … 約束の 3 倍を過ぎた → 「作成が終わっていません(依頼は届いています)」
2270
- * - `ready` … ID がある → 手順の画像/Google カレンダーに追加/ID のコピー/共有を更新/
2271
- * この案件のミーティングを作成(#452 の並び)
2268
+ * - `ready` … ID がある → 手順の画像/Google カレンダーに追加/ID のコピー/
2269
+ * この案件のミーティングを作成(#452 の並び。「共有を更新」は #544 で外した)
2272
2270
  * - `failed` … 作れなかった → 理由の1行+もう一度
2273
2271
  *
2274
2272
  * 🔴 **`none` は「行が 1 本も無い」ときだけ。** 契約(裏の経路 #427)は
@@ -2302,28 +2300,18 @@ function calendarPhase(s, rows, ctx, nowMs) {
2302
2300
  var unclear = { phase: "unclear", calendarId: "", detail: "", note: "", at: at };
2303
2301
  // ---- ID があれば必ず ready(最新の行が何であっても降ろさない) ----
2304
2302
  if (id) {
2305
- // 進行中・失敗は**添える1行**として渡す。「作れませんでした」とは言わない
2303
+ // 失敗は**添える1行**として渡す。「作れませんでした」とは言わない
2306
2304
  //(出来ているので嘘になり、押し直すと 2 本目が作られる)
2307
2305
  //
2308
- // 🔴 **「共有を更新しています」は、共有の更新が実際に立っているときだけ出す(#452)。**
2309
- // 「作る」と「共有を更新」は**サーバに飛ぶ依頼が同じ1本**(本文は projectKey だけ・
2310
- // 冪等・#427)なので、`status` だけでは種別が分からない。区別できる材料は
2311
- // **その行が ID を載せているか**だけ:
2312
- // - intake は行の `ref` に `write.response.calendarId || "calendar"` を書く
2313
- // (`infra/lambda/intake/index.mjs`)。**カレンダーが出来ている案件で「共有を更新」を
2314
- // 押すと、ID を載せた `requested` の行**になる(`planCalendarItemWrite` が
2315
- // `existing.calendarId` を返すため)
2316
- // - 一括作成がまだ閉じていない案件は `calendarId` が空なので**番兵のまま**で、
2317
- // ID は夜間の集計(fleet)からしか引けない
2318
- // ⚠ 行は「増える」のではなく**同じ SK に上書き**される(接続状態ストアの SK は `ref` を
2319
- // 含むので、番兵の行と ID の行は別の SK)。だから「作成時の行が残っているから分かる」
2320
- // ではなく、**押した依頼そのものが ID を載せて書かれるから分かる**。
2321
- // ここを見ないと、**一括作成の直後に「共有を更新しています(最長 20 分)。」が出る**
2322
- //(#452 の実機確認)。押してもいない人には、できることの無い1行になる。
2306
+ // 🔴 **`requested`(依頼が立っている)は note にしない(#544)。**
2307
+ // かつては「共有を更新しています(最長 20 分)。」を出すための材料で、
2308
+ // 「押した人にだけ出す」ために **その行が ID を載せているか**を見ていた(#452)。
2309
+ // **その画面のボタンは #544 で外した**ので、出す先が無い——残すと
2310
+ // 「誰も読まない値を計算し続ける」ことになり、次に触る人が使えると誤解する。
2311
+ // ⚠ **失敗の枝は残す。** これは「共有を更新」由来とは限らず、
2312
+ // **作成の書き戻し失敗**(カレンダーは出来ているのに報告が書けなかった回)でも立つ
2323
2313
  var note = "";
2324
- if (status === "requested" && calendarIdFromRows(rows))
2325
- note = "requested";
2326
- else if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0)
2314
+ if ((BAD_STATE_STATUSES.calendar || []).indexOf(status) >= 0)
2327
2315
  note = detail || "failed";
2328
2316
  return { phase: "ready", calendarId: id, detail: "", note: note, at: at };
2329
2317
  }
@@ -3975,7 +3963,10 @@ a.ds-tile:hover { text-decoration: none; }
3975
3963
  .ds-probe-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
3976
3964
  /* いつ測った結果かを必ず添える。夜間のスナップショットと混ざると、どちらの話か読めない */
3977
3965
  .ds-probe-at { font-size: 11.5px; color: var(--muted); }
3978
- .ds-probe-help { margin: 6px 0 0; font-size: 12px; line-height: 1.7; color: #6b7280;
3966
+ /* flex: 1 1 100% #548 で足した——「接続を確認」の即答は、この段を .ds-probe の中では
3967
+ なく**行(.ds-card)に直接**置く。付けないと ID と「外す」の横に押し込まれて折り返す
3968
+ (.ds-gate-help と同じ理由)。.ds-probe の中(display: block)では効かない */
3969
+ .ds-probe-help { flex: 1 1 100%; margin: 6px 0 0; font-size: 12px; line-height: 1.7; color: #6b7280;
3979
3970
  min-width: 0; white-space: normal; overflow-wrap: anywhere; }
3980
3971
  /* 「アクセス不可」の原因の候補(0.12.2)。**箇条書きにする**——候補が3つあるので、
3981
3972
  一続きの文章にすると読む人が「自分がどれなのか」を自分で切り出すことになる
@@ -4098,7 +4089,8 @@ a.ds-tile:hover { text-decoration: none; }
4098
4089
  見分けられなかったので、下の2規則(主ボタンと控えめな文字リンク)に格を分けてある */
4099
4090
  /* 必ず 1 回押す主ボタン(#452)。見た目は .ds-toggle-btn から借りるので、ここは
4100
4091
  リンク固有の分だけ——値を写すと色と枠が 2 枚になる。**太さも足さない**
4101
- (隣の「共有を更新」は既定の太さなので、ここだけ太いと「同じ見た目」から外れる) */
4092
+ (借り元の .ds-toggle-btn が既定の太さなので、ここだけ太いと「同じ見た目」から外れる。
4093
+ 隣に並んでいた「共有を更新」は #544 で外した) */
4102
4094
  .mt-cal-primary { display: inline-block; margin: 8px 0 0; text-decoration: none; }
4103
4095
  /* 素の a:hover が下線を引く。**ボタンの見た目に下線が出ると壊れて見える**ので打ち消す。
4104
4096
  ⚠ この CSS はテンプレート文字列の中なので、コメントにバッククォートを書かないこと */
@@ -4543,10 +4535,9 @@ ${deriveCapTile.toString()}
4543
4535
  // (語を2枚持たないため)ので、宣言の前に呼ぶと undefined を読む
4544
4536
  ${isCalendarId.toString()}
4545
4537
  ${calendarStateRow.toString()}
4546
- // calendarIdOf calendarPhase の両方が呼ぶ(#452)。「ストア側に ID を持つ行があるか」は
4547
- // 「共有を更新しています」を出すかどうかの唯一の材料。**読みやすさのために呼ぶ側より前に
4548
- // 置いてあるだけ**——関数宣言は巻き上がるので、上の BAD_STATE_STATUSES(var)と違って
4549
- // 順序そのものは効かない
4538
+ // calendarIdOf が呼ぶ(#452。#544 calendarPhase 側の呼び出しは無くなった)。
4539
+ // **読みやすさのために呼ぶ側より前に置いてあるだけ**——関数宣言は巻き上がるので、
4540
+ // 上の BAD_STATE_STATUSES(var)と違って順序そのものは効かない
4550
4541
  ${calendarIdFromRows.toString()}
4551
4542
  ${calendarIdOf.toString()}
4552
4543
  ${calendarRowCount.toString()}
@@ -6629,18 +6620,48 @@ ${calendarAddUrl.toString()}
6629
6620
  });
6630
6621
  return p;
6631
6622
  }
6623
+ /**
6624
+ * ボタンの detail が「機械語の痕跡」でしかない能力(#548・設計v2 🔴2)。
6625
+ *
6626
+ * この3つは中央が艦隊の鍵で叩く経路(gate)で、押した回の detail には判定の語そのもの
6627
+ * ("unreachable")や生のエラー("ratelimited")が入る。**それ以外の能力
6628
+ * (design / materials / issues)のボタンは、engine の取り込みと同じ判定器の
6629
+ * 同じ1文を書く**ので、出さないと「読めません」の理由が押した人に届かない。
6630
+ */
6631
+ var GATE_BUTTON_CAPS = ["chat", "dev", "google-chat"];
6632
+ /**
6633
+ * 外部ソースの種別 → 接続状態ストアの能力 id。**上の除外を引くためだけに使う。**
6634
+ * 知らない種別は空文字(=除外に当たらない=理由を出す側に倒す)。
6635
+ */
6636
+ function gateCapOfType(type) {
6637
+ var t = String(type || "");
6638
+ if (t === "slack") return "chat";
6639
+ if (t === "google-chat") return "google-chat";
6640
+ if (t.indexOf("github") === 0) return "dev";
6641
+ return "";
6642
+ }
6632
6643
  /**
6633
6644
  * 休止状態の行に出す「最後に測った理由」(表にエントリが無い状態のフォールバック)。
6634
6645
  * needed(次の一手・URL 込み)を優先し、無ければ detail。
6635
6646
  *
6636
- * 🔴 **書き手が button の detail は出さない。** ボタンは detail に判定の語そのもの
6637
- * "unreachable")や生のエラー("ratelimited")を書く——lastFailDetail を空で潰さない
6638
- * ための痕跡で、表示用の値ではない(intake の op=probe のコメント)。人向けの文を持つのは
6639
- * needed の側。夜間(gold)の detail は人向けの文なので出す
6647
+ * 🔴 **gate 経路の書き手が button の detail は出さない。** Slack / GitHub / Google Chat の
6648
+ * ボタンは detail に判定の語そのもの("unreachable")や生のエラー("ratelimited")を
6649
+ * 書く——lastFailDetail を空で潰さないための痕跡で、表示用の値ではない(intake の
6650
+ * op=probe のコメント)。人向けの文を持つのは needed の側。夜間(gold)の detail
6651
+ * 人向けの文なので出す。
6652
+ *
6653
+ * 🔴 **除外は能力で絞る(#548)。** Figma / Drive / Backlog のボタンは中央がその場で
6654
+ * 案件の鍵で叩いて、engine の取り込みと同じ判定器の同じ1文を detail に書く。
6655
+ * 「button なら出さない」と一律にすると、**押した人にだけ理由が届かない**
6656
+ * (夜間まで待てば同じ文が出る、という逆立ちした画面になる)。
6657
+ *
6658
+ * @param s needed / detail / gateBy に加えて **cap(接続状態ストアの能力 id)** を渡す。
6659
+ * cap を渡さない呼び方は「除外に当たらない」=理由を出す側に倒れる
6640
6660
  */
6641
6661
  function stateReasonOf(s) {
6642
6662
  if (s.needed) return String(s.needed);
6643
- if (s.detail && s.gateBy !== "button") return String(s.detail);
6663
+ var hidden = s.gateBy === "button" && GATE_BUTTON_CAPS.indexOf(String(s.cap || "")) >= 0;
6664
+ if (s.detail && !hidden) return String(s.detail);
6644
6665
  return "";
6645
6666
  }
6646
6667
  /**
@@ -6988,23 +7009,9 @@ ${calendarAddUrl.toString()}
6988
7009
  function calendarCanRequest() { return !CALENDAR_DENIED && !!actorTicket(); }
6989
7010
 
6990
7011
  /**
6991
- * この画面で「共有を更新」を押したか(#452)。
6992
- *
6993
- * 🔴 **押した手応えを消さないために要る。** 「共有を更新しています(最長 N 分)。」は
6994
- * ストアの行が ID を載せているときだけ出す(calendarPhase の項)が、**一括作成が
6995
- * まだ閉じていない案件では、押しても番兵の行しか書かれない**。押すと行の時刻が変わって
6996
- * meetingCalendarSig が変わり、liveList が箱を作り直す——ボタン脇の
6997
- * 「✓ 依頼しました」ごと消えるので、**押した人の画面が押す前と 1 文字も変わらない**。
6998
- *
6999
- * 判定を 2 枚にはしていない(calendarPhase は純関数のまま)。ここが持っているのは
7000
- * 「サーバが何を知っているか」ではなく **「この人が押したか」**で、これは
7001
- * サーバの行から復元できない——CALENDAR_DENIED を画面が覚えているのと同じ事情。
7002
- * 画面を開き直せば消える(そのときは行が答えを持っている)。
7003
- *
7004
7012
  * ⚠ ここから下は CLIENT_JS のテンプレート文字列の中。**コメントにバッククォートを
7005
7013
  * 書かないこと**(文字列がそこで終わり、ビルドが丸ごと壊れる)。
7006
7014
  */
7007
- var CALENDAR_SHARE_PRESSED = false;
7008
7015
 
7009
7016
  /**
7010
7017
  * 案件カレンダーの行(#428・設計は #418 の §6)。
@@ -7088,26 +7095,20 @@ ${calendarAddUrl.toString()}
7088
7095
  // 色は**見る人ごとの設定**で、仕組みでは揃えられない(#418 の調査4)。
7089
7096
  // 質問にはせず、事実として1行だけ置く(オーナー決定 A-5: 画面に計器は出さない)
7090
7097
  box.appendChild(el("p", { class: "mt-cal-note", text: "色はご自身のカレンダーで変えられます。" }));
7091
- // 🔴 **「押してください」は、押せる人にだけ出す(#452)。** calendarRequestButton は
7092
- // 名乗り券やオーナー権限が無い人にはボタンではなく別の案内を返すので、
7093
- // その人に説明の1行だけ出すと「押してください」が嘘になる
7094
- if (calendarCanRequest()) {
7095
- // 🔴 **毎時の自動追従(#451)を必ず書く。** これが無いと「押さないと共有されない」と
7096
- // 読ませることになり、押し忘れを心配させる。押すのは「すぐに反映したいとき」だけ
7097
- box.appendChild(el("p", { class: "mt-cal-note", text: "案件にメンバーを足したら押してください。その人もこのカレンダーを見られるようになります(1 時間ごとに自動でも更新されます)" }));
7098
- }
7099
- // 作成済みのまま進行中・失敗の報告が最新なら、その1行だけ添える。
7100
- // **「作れませんでした」とは言わない**(出来ているので嘘になり、押し直すと 2 本目が作られる)
7098
+ // 🔴 **「共有を更新」のボタンと説明はここに在った(#452)。#544 で外した。**
7099
+ // ポータル側が**メンバーの増減のたびに**共有を直させるようになったので、
7100
+ // 押す理由が無くなった(取りこぼしは毎時の便が最長 1 時間で埋める)。
7101
+ // 押す場所が無い画面に「共有を更新しています」「終わっていません」だけを出さない
7102
+ // ——利用者にできることが無い 1 行になる。**受け口(?op=calendar-request)は残っている**
7103
+ // ので、運用が直接叩いて是正できる(docs/calendar-provisioning-runbook.md)。
7101
7104
  //
7102
- // 🔴 **押した人には必ず出す(#452)。** 一括作成がまだ閉じていない案件では、押しても
7103
- // 行は番兵のままなので cal.note は空になる。そのまま出さないと、押した直後の
7104
- // 作り直しで「✓ 依頼しました」も消え、**画面が押す前と 1 文字も変わらない**
7105
- if (cal.note === "requested" || CALENDAR_SHARE_PRESSED) {
7106
- box.appendChild(el("p", { class: "mt-cal-note", text: "共有を更新しています(最長 " + CALENDAR_WAIT_MIN + " 分)。" }));
7107
- } else if (cal.note) {
7105
+ // 作成済みのまま失敗の報告が最新なら、その1行だけ添える。
7106
+ // **これは「共有を更新」由来とは限らない**(作成の書き戻し失敗でも立つ。#452)ので、
7107
+ // ボタンと一緒に消さない。**「作れませんでした」とは言わない**
7108
+ //(出来ているので嘘になり、押し直すと 2 本目が作られる)
7109
+ if (cal.note) {
7108
7110
  box.appendChild(el("p", { class: "mt-cal-warn", text: "共有の更新が終わっていません(" + cal.note + ")。" }));
7109
7111
  }
7110
- box.appendChild(calendarRequestButton("共有を更新", "share"));
7111
7112
  // 🔴 **主ボタンと格を分ける(#452)。** 同じ大きさ・同じ色で2本並ぶと、
7112
7113
  // 必ず押すもの(Google カレンダーに追加)がどれか分からない。予定を作るのは
7113
7114
  // たまにしかしないので、一番下に小さく薄い文字リンクで出す
@@ -7154,10 +7155,11 @@ ${calendarAddUrl.toString()}
7154
7155
  }
7155
7156
 
7156
7157
  /**
7157
- * 依頼を出すボタン。**3つ(作る・共有を更新・もう一度)とも同じ1本の経路**で、
7158
+ * 依頼を出すボタン。**2つ(作る・もう一度)とも同じ1本の経路**で、
7158
7159
  * 押した直後の見え方だけが違う(裏の経路は冪等・#427)。
7160
+ * ⚠ 3 つ目の「共有を更新」は #544 で外した(受け口はそのまま残っている)。
7159
7161
  *
7160
- * **確認を出すのは「作る」だけ。** 「共有を更新」「もう一度」は取り返しがつく
7162
+ * **確認を出すのは「作る」だけ。** 「もう一度」は取り返しがつく
7161
7163
  * (同じ依頼が立ち直るだけ)ので、確認を挟むと押す手が増えるだけになる。
7162
7164
  *
7163
7165
  * 🔴 **本文は空**(projectKey だけ)。共有先のメールをブラウザから受け取らない——
@@ -7210,9 +7212,6 @@ ${calendarAddUrl.toString()}
7210
7212
  // 待ちの言い方は行から描く 1 行(pending)と揃える(#540)。
7211
7213
  // 片方だけ「最長 20 分」だと、同じ待ちが 2 通りに見える
7212
7214
  msg.textContent = "✓ 依頼しました。作成中です(数分〜十数分)。";
7213
- // **押したことを覚える(#452)。** 下の取り直しで箱ごと作り直されると
7214
- // この 1 行は消えるので、消えない側(行から描く 1 行)に引き継ぐ
7215
- if (kind === "share") CALENDAR_SHARE_PRESSED = true;
7216
7215
  // pending-ops(#388)は使わない。**あれは registeredNow のキーで live 判定する
7217
7216
  // 仕組みで、カレンダーは stateNow 由来**。fresh で取り直せば「作成中」が出る
7218
7217
  loadLiveFleet(true);
@@ -10913,22 +10912,26 @@ ${calendarAddUrl.toString()}
10913
10912
  return row;
10914
10913
  }
10915
10914
  if (r.status === "ok" || r.status === "no_token" || r.status === "missing_scope" || r.status === "not_in_channel") return row;
10916
- var reason = stateReasonOf({ needed: r.needed, detail: r.detail, gateBy: r.by });
10915
+ // **cap を渡す(#548)。** この3能力(design / materials / issues)のボタンは
10916
+ // 判定器の1文を detail に書くので、gate 経路の除外に当てない(stateReasonOf の項)
10917
+ var reason = stateReasonOf({ needed: r.needed, detail: r.detail, gateBy: r.by, cap: capId });
10917
10918
  // needed は Slack の行と同じ部品を通す(URL がリンクになる・スコープ名がラベル化される)
10918
10919
  if (reason) row.appendChild(r.needed ? neededNode(reason, "ds-gate-help") : el("p", { class: "ds-gate-help", text: reason }));
10919
10920
  return row;
10920
10921
  }
10921
10922
 
10922
10923
  /* -------------------------------------------------------------------------
10923
- * dispatch の「接続を確認」(#523 S3・Backlog / Drive / Figma)
10924
+ * 「接続を確認」と「今すぐ同期」(#523 S3・#548・Backlog / Drive / Figma)
10924
10925
  * -------------------------------------------------------------------------
10925
- * Slack / GitHub / Google Chat の確認(「probeSection」)は中央がその場で叩いて
10926
- * 答えを返すが、この3つは**中央が鍵を読まない**(#105 決定5)ので、押すと
10927
- * その案件の同期が1回起きるだけ。答えは接続状態ストアの行として後から届く。
10926
+ * #548 までは、この3つは中央が鍵を読まない(#105 決定5)ので、押すとその案件の同期が
10927
+ * 1回起きるだけだった。オーナーがその境界を覆し、いまは「接続を確認」は Slack / GitHub と
10928
+ * 同じく**中央がその場で叩いて答えを返す**(即答・「showProbeVerdict」)。同期を1
10929
+ * 起こす従来の動きは「今すぐ同期」(送る値に 「sync: true」)に移った。
10928
10930
  *
10929
- * だから画面がすることは3つ:
10931
+ * 待ちの仕組み(下)は「今すぐ同期」と、Backlog が IP 制限で叩けず夜間と同じ経路へ
10932
+ * 倒れた回のために残る:
10930
10933
  * 1. 押す前にその行の 「at」 を控える(🔴2・壁時計を使わない)
10931
- * 2. 「確認を開始しました」を出して、取り直し(op=state)を回す
10934
+ * 2. 「同期を開始しました」を出して、取り直し(op=state)を回す
10932
10935
  * 3. 控えた 「at」 より新しい行が届いたら、待ちを解いて札を描き直す
10933
10936
  * ——札そのものは 「appendReachableChip」 が描くので、ここは待ちの表示だけ
10934
10937
  * ----------------------------------------------------------------------- */
@@ -11006,19 +11009,105 @@ ${calendarAddUrl.toString()}
11006
11009
  }
11007
11010
 
11008
11011
  /**
11009
- * 接続先1件の「接続を確認」(ボタン+待ちの1文)を行に足す。
11012
+ * 断られた回の後始末。**待ちに入れない**(出ない結果を15分待たせない)。
11013
+ * 文言はサーバのものをそのまま出す(回数制限・鍵未登録・未登録の接続先・
11014
+ * 登録簿が読めない・ロール未登録・起動失敗が、全部ここへ来る)。
11015
+ */
11016
+ function probeDeclined(note, key, j) {
11017
+ note.textContent = (j && j.message) || "確認できませんでした。";
11018
+ delete PROBE_WAITS[key];
11019
+ }
11020
+
11021
+ /**
11022
+ * 従来の待ちに入る(同期が1回起きて、答えは接続状態ストアの行として後から届く)。
11023
+ * 通るのは2つ——「今すぐ同期」と、Backlog が IP 制限でこの場では叩けず
11024
+ * **夜間と同じ経路へ倒れた回**(#548)。
11025
+ *
11026
+ * 🔴 **控えた 「at」 で待つ(🔴2・壁時計を使わない)。** 押した時刻で見ると、
11027
+ * 端末の時計がずれている人には永久に届かない(または届く前に届いたことになる)。
11028
+ */
11029
+ function probeEnterWait(note, key, capId, ref, before, j) {
11030
+ PROBE_WAITS[key] = {
11031
+ cap: capId, ref: ref,
11032
+ at: (before && before.at) || "",
11033
+ startedAt: Date.now(),
11034
+ message: (j && j.message) || "",
11035
+ };
11036
+ note.textContent = (j && j.message) || "";
11037
+ // 待っているあいだは取り直しを回す(届いた行で一覧が描き直される)
11038
+ refreshStateTimer();
11039
+ }
11040
+
11041
+ /**
11042
+ * 即答(#548 の direct)を行に映す。**「probeSection」 と同じ見た目にする**——
11043
+ * 夜間のスナップショットの判定といま測った判定を**同じ行に並べない**ため
11044
+ * (並べると「⚠️ と 接続OK が同時に出ている」になり、この節で何度も直してきた
11045
+ * 食い違いを画面の側から作ることになる)。置き換えたことが分かるよう、
11046
+ * いつ測ったかを必ず添える。
11047
+ *
11048
+ * 🔴 **札が無い行もある**(一度も測っていない接続先= 「appendReachableChip」 が
11049
+ * 何も足していない行)。その場合は札を作って**ボタンの前**に差し込む——後ろに置くと
11050
+ * 行の読み順(札 → ボタン)が崩れる。
11051
+ *
11052
+ * @param anchor 差し込む位置の目印(1つ目のボタン)
11053
+ */
11054
+ function showProbeVerdict(row, capId, anchorIn, j) {
11055
+ var gate = dsGateForCap(capId, j.gate);
11056
+ // 差し込む位置は**行の最初のボタン**の前(「外す」が出る行では「外す」の前)。
11057
+ // 「接続を確認」の前に置くと、外せる行だけ「外す → 札 → ボタン」の並びになる(🟡10)
11058
+ var anchor = anchorIn;
11059
+ var btns = row.querySelectorAll(".ds-toggle-btn");
11060
+ for (var bi = 0; bi < btns.length; bi++) {
11061
+ // **行の直下にあるボタンだけ**が目印になれる(入れ子の中のボタンの前には差し込めない)
11062
+ if (btns[bi].parentNode === row) { anchor = btns[bi]; break; }
11063
+ }
11064
+ var chip = row.querySelector(".ds-gate");
11065
+ if (!chip) {
11066
+ chip = el("span", { class: "ds-gate" });
11067
+ row.insertBefore(chip, anchor);
11068
+ }
11069
+ chip.className = "ds-gate " + gate.cls;
11070
+ chip.textContent = gate.text;
11071
+ var at = row.querySelector(".ds-gate-at");
11072
+ if (!at) {
11073
+ at = el("span", { class: "ds-gate-at" });
11074
+ row.insertBefore(at, anchor);
11075
+ }
11076
+ at.textContent = "いま測った結果(" + (dsFmtTime(j.checkedAt) || "たった今") + ")";
11077
+ // **夜間の判定に添えてあった案内は隠す。** 置き換えた判定と食い違う案内が下に残ると、
11078
+ // 同じ行が2つのことを言う(probeSection の stale 処理と同じ)
11079
+ var stale = row.querySelectorAll(".ds-gate-help");
11080
+ for (var si = 0; si < stale.length; si++) stale[si].hidden = true;
11081
+ // **押し直しで段が増えないよう、前回の結果は消してから出す**(probeSection の札と同じ)
11082
+ var old = row.querySelectorAll(".ds-probe-help");
11083
+ for (var oi = 0; oi < old.length; oi++) old[oi].remove();
11084
+ // 次の一手は語の表(DS_GATE_CAP)が持っていればそれ。無ければ判定器の1文(detail)
11085
+ // ——どちらも**1段だけ**(帯は症状・行は一手・繰り返さない)
11086
+ var nodes = gateHelpNodes(gate, "ds-probe-help");
11087
+ if (!nodes.length && j.detail) nodes = [el("p", { class: "ds-probe-help", text: String(j.detail) })];
11088
+ nodes.forEach(function (n) { row.appendChild(n); });
11089
+ }
11090
+
11091
+ /**
11092
+ * 接続先1件の「接続を確認」「今すぐ同期」(2ボタン+その場の返事1文)を行に足す。
11093
+ *
11094
+ * **なぜ2つに割れたか(#548)。** 中央が案件の鍵で1回だけ叩けるようになった
11095
+ * (オーナーが #105 決定5 を覆した)ので、「接続を確認」は**その場で答えが返る**
11096
+ * ——押した人を数分〜十数分待たせない。一方「取り込みをやり直したい」は別の用事なので、
11097
+ * 同期を1回起こす従来の動きは「今すぐ同期」として残す(送る値は 「sync: true」)。
11010
11098
  *
11011
11099
  * **ボタンは行の中**(Slack の行と同じ・2026-08-20 の「1行が2段になる」を繰り返さない)。
11012
- * 待ちの1文は 「.ds-gate-help」 で行の最後に落とす(「appendReachableReason」 の項)。
11100
+ * 🔴 **その場の返事(「.ds-probe-wait」)は2つのボタンで1つ・行の最後。** 2つ持つと
11101
+ * 行が2段になり、片方に古い返事が残る。「ds-gate-help」 は付けない——あれは
11102
+ * 「最後に測った理由」の1段で、行の補足が1段であることを見張る検査(#523 S1)が
11103
+ * 数えている。押した人へのその場の返事は別の話なので、専用の class を持たせる。
11013
11104
  */
11014
11105
  function appendDispatchProbe(row, capId, ref) {
11015
11106
  var type = dispatchProbeTypeOf(capId);
11016
11107
  // **書き込み経路が無ければ出さない**(押しても何も起きないボタンを置かない)
11017
11108
  if (!type || !SITE.intake || !ref) return row;
11018
- var btn = el("button", { class: "ds-toggle-btn", type: "button", text: "接続を確認" });
11019
- // 🔴 **「ds-gate-help」 を付けない。** あれは「最後に測った理由」の1段で、
11020
- // 行の補足が1段であることを見張る検査(#523 S1)が数えている。待ちの1文は
11021
- // 別の話(押した人へのその場の返事)なので、専用の class を持たせる
11109
+ var checkBtn = el("button", { class: "ds-toggle-btn", type: "button", text: "接続を確認" });
11110
+ var syncBtn = el("button", { class: "ds-toggle-btn", type: "button", text: "今すぐ同期" });
11022
11111
  var note = el("p", { class: "ds-probe-wait" });
11023
11112
  var phase = probeWaitPhaseOf(capId, ref);
11024
11113
  var wait = probeWaitOf(capId, ref);
@@ -11026,40 +11115,72 @@ ${calendarAddUrl.toString()}
11026
11115
  else if (phase === "timeout") note.textContent = PROBE_WAIT_TIMEOUT_TEXT;
11027
11116
  else note.hidden = true;
11028
11117
 
11029
- btn.addEventListener("click", async function () {
11030
- var endBusy = beginBusy(btn, "確認中…");
11118
+ checkBtn.addEventListener("click", async function () {
11119
+ var endBusy = beginBusy(checkBtn, "確認中…");
11031
11120
  note.hidden = false;
11032
11121
  note.textContent = "確認中…";
11033
11122
  // 🔴 **押す前に、いまの行の 「at」 を控える(🔴2)。** 行が無ければ空文字
11034
- //(どんな 「at」 が来ても「新しい」と読める)
11123
+ //(どんな 「at」 が来ても「新しい」と読める)。使うのは IP 制限で倒れた回だけだが、
11124
+ // 応答が返ってからでは「押す前の行」を控えられない
11035
11125
  var before = probeLatestRow(capId, ref);
11036
11126
  var key = probeWaitKey(capId, ref);
11037
11127
  try {
11038
11128
  var res = await callIntake("probe", { type: type, ref: ref });
11039
11129
  var j = await res.json().catch(function () { return {}; });
11040
11130
  if (!res.ok || j.queued === false) {
11041
- // サーバの文言をそのまま出す(回数制限・鍵未登録・ロール未登録・起動失敗)
11042
- note.textContent = j.message || "確認できませんでした。";
11043
- delete PROBE_WAITS[key];
11131
+ probeDeclined(note, key, j);
11044
11132
  endBusy();
11045
11133
  return;
11046
11134
  }
11047
- PROBE_WAITS[key] = {
11048
- cap: capId, ref: ref,
11049
- at: (before && before.at) || "",
11050
- startedAt: Date.now(),
11051
- message: j.message || "",
11052
- };
11053
- note.textContent = j.message || "";
11054
- // 待っているあいだは取り直しを回す(届いた行で一覧が描き直される)
11055
- refreshStateTimer();
11135
+ if (!j.gate) {
11136
+ // 語が返らない回=この場では叩けなかった回。Backlog IP 制限
11137
+ //(「reason」 「ip-restricted」)で夜間と同じ経路へ倒したときと、
11138
+ // 即答をまだ知らない古い Lambda が相手のとき。どちらも従来どおり待つ
11139
+ probeEnterWait(note, key, capId, ref, before, j);
11140
+ endBusy();
11141
+ return;
11142
+ }
11143
+ // 即答。🔴 **待ちには入れない(🟡7)**——結果はもう行に出ているので、
11144
+ // 待ちに入れると「届いた行」を探して15分待つ空回りになる
11145
+ delete PROBE_WAITS[key];
11146
+ note.hidden = true;
11147
+ note.textContent = "";
11148
+ showProbeVerdict(row, capId, checkBtn, j);
11149
+ // ストアにも 「button」 の行が書かれている。**取りに行くのは1回だけ**——
11150
+ // 届いた行で一覧が描き直され、この行の札が正本の値に揃う
11151
+ loadLiveFleet();
11056
11152
  } catch (e) {
11057
11153
  note.textContent = "通信に失敗しました。";
11058
11154
  delete PROBE_WAITS[key];
11059
11155
  }
11060
11156
  endBusy();
11061
11157
  });
11062
- row.appendChild(btn);
11158
+
11159
+ syncBtn.addEventListener("click", async function () {
11160
+ var endBusy = beginBusy(syncBtn, "同期中…");
11161
+ note.hidden = false;
11162
+ note.textContent = "同期中…";
11163
+ // 押す前の 「at」 を控える(上と同じ理由・壁時計を使わない)
11164
+ var before = probeLatestRow(capId, ref);
11165
+ var key = probeWaitKey(capId, ref);
11166
+ try {
11167
+ var res = await callIntake("probe", { type: type, ref: ref, sync: true });
11168
+ var j = await res.json().catch(function () { return {}; });
11169
+ if (!res.ok || j.queued === false) {
11170
+ probeDeclined(note, key, j);
11171
+ endBusy();
11172
+ return;
11173
+ }
11174
+ probeEnterWait(note, key, capId, ref, before, j);
11175
+ } catch (e) {
11176
+ note.textContent = "通信に失敗しました。";
11177
+ delete PROBE_WAITS[key];
11178
+ }
11179
+ endBusy();
11180
+ });
11181
+
11182
+ row.appendChild(checkBtn);
11183
+ row.appendChild(syncBtn);
11063
11184
  row.appendChild(note);
11064
11185
  return row;
11065
11186
  }
@@ -11068,7 +11189,7 @@ ${calendarAddUrl.toString()}
11068
11189
  * 一覧に出ている接続先の「届くか」をひとつの文字列にする(描き直しの要否の判定用)。
11069
11190
  *
11070
11191
  * **待ちの相も混ぜる(#523 S3)。** 混ぜないと、15分の上限に達しても一覧が
11071
- * 作り直されず、「確認を開始しました」が出たまま止まる(待てば出ると言い続ける形)。
11192
+ * 作り直されず、「同期を開始しました」が出たまま止まる(待てば出ると言い続ける形)。
11072
11193
  */
11073
11194
  function reachSigOf(capId, refs) {
11074
11195
  var now = Date.now();
@@ -12955,7 +13076,9 @@ ${calendarAddUrl.toString()}
12955
13076
  var restingParts = restingHelpPartsOf(s.type, s.gate);
12956
13077
  if (restingParts) card.appendChild(restingHelpNode(restingParts, "ds-gate-help ds-gate-resting"));
12957
13078
  else {
12958
- var reason = stateReasonOf(s);
13079
+ // **能力に畳んでから渡す(#548)。** ここへ来るのは Slack / GitHub / Google Chat の
13080
+ // 行だけなので、ボタンが書いた detail(判定語)は従来どおり出さない
13081
+ var reason = stateReasonOf({ needed: s.needed, detail: s.detail, gateBy: s.gateBy, cap: gateCapOfType(s.type) });
12959
13082
  if (reason) card.appendChild(neededNode(reason, "ds-gate-help ds-gate-reason"));
12960
13083
  }
12961
13084
  }
@@ -12969,9 +13092,9 @@ ${calendarAddUrl.toString()}
12969
13092
  // 判定と文面は infra/lambda/shared/github-gate.mjs 側(画面に分岐を持たせない)。
12970
13093
  // **Google Chat は #347(同じ理由)。** スペースにアプリを追加した人が、その場で
12971
13094
  // 直ったことを確かめられる。判定は infra/lambda/shared/google-chat-gate.mjs 側。
12972
- // **Backlog / Figma / Drive は「接続を確認」の型が違う**(#523 S3・レジストリの
12973
- // 「probe: dispatch」)。中央が叩くのではなく案件の同期を起こすので、ここではなく
12974
- // それぞれの一覧の行が 「appendDispatchProbe」 で出す。
13095
+ // **Backlog / Figma / Drive は「接続を確認」の型が違う**(#523 S3・#548・レジストリの
13096
+ // 「probe: direct」)。中央が案件の鍵で叩いて即答し、「今すぐ同期」も並ぶので、
13097
+ // ここではなくそれぞれの一覧の行が 「appendDispatchProbe」 で出す。
12975
13098
  //
12976
13099
  // **origin === "explicit" でも出す。** 確認は読むだけで、どの登録ファイル由来かに
12977
13100
  // 関係なく効く(書き込みになる「表示名を更新する」だけが由来で出し分かれる)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.53",
3
+ "version": "0.12.55",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -21,7 +21,7 @@
21
21
  "build": "tsc -p tsconfig.json",
22
22
  "prepublishOnly": "npm run build",
23
23
  "demo": "npm run build && node dist/cli.js build ../../aidd-project-cortex/Cortex/Decisions/records --out .demo-site",
24
- "test": "npm run test:syntax && npm run build && node --test test/*.test.mjs",
24
+ "test": "npm run test:syntax && npm run build && node --test --test-concurrency=4 test/*.test.mjs",
25
25
  "test:syntax": "node --test test/embedded-js-syntax.test.mjs"
26
26
  },
27
27
  "dependencies": {