@takagaki/cortex-decisions-viewer 0.12.30 → 0.12.32

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.
@@ -22,17 +22,20 @@ exports.PENDING_OPS_LIMITS = void 0;
22
22
  exports.pendingKindsTable = pendingKindsTable;
23
23
  exports.pendingOpsKey = pendingOpsKey;
24
24
  exports.pendingSlackChannelId = pendingSlackChannelId;
25
+ exports.pendingIsSpaceRef = pendingIsSpaceRef;
25
26
  exports.pendingGoogleChatSpaceRef = pendingGoogleChatSpaceRef;
26
27
  exports.pendingRepo = pendingRepo;
27
28
  exports.pendingFigmaKey = pendingFigmaKey;
28
29
  exports.pendingDriveFolderId = pendingDriveFolderId;
29
30
  exports.backlogTargetOf = backlogTargetOf;
31
+ exports.pendingIsSlackTeamId = pendingIsSlackTeamId;
30
32
  exports.pendingIssuesRef = pendingIssuesRef;
31
33
  exports.pendingLabel = pendingLabel;
32
34
  exports.pendingOpOf = pendingOpOf;
33
35
  exports.sanitizePendingOps = sanitizePendingOps;
34
36
  exports.rememberPendingOp = rememberPendingOp;
35
37
  exports.pendingSiteTimeMs = pendingSiteTimeMs;
38
+ exports.pendingStale = pendingStale;
36
39
  exports.pendingOpBuildCaughtUp = pendingOpBuildCaughtUp;
37
40
  exports.pendingLiveRowMatches = pendingLiveRowMatches;
38
41
  exports.pendingOpLiveAgrees = pendingOpLiveAgrees;
@@ -101,24 +104,42 @@ function pendingSlackChannelId(input) {
101
104
  if (/^[CG][A-Z0-9]{6,}$/.test(s))
102
105
  return s;
103
106
  const m = s.match(/\/archives\/([CG][A-Z0-9]{6,})/);
104
- return m ? m[1] : null;
107
+ if (m)
108
+ return m[1];
109
+ // ブラウザのアドレスバーの形(#398)。**team_id は使わない**——どのワークスペースかは
110
+ // サーバが実測して決める。ここで取るのはチャンネルIDだけ
111
+ // (team_id の位置の形は shared/secret-names.mjs の isSlackTeamId と同じ。値は採らない)
112
+ const client = s.match(/^https?:\/\/app\.slack\.com\/client\/[TE][A-Z0-9]{6,20}\/([CG][A-Z0-9]{6,})/);
113
+ return client ? client[1] : null;
114
+ }
115
+ /**
116
+ * スペース ref の形(サーバの `GOOGLE_CHAT_SPACE_REF` の写し)。ずれは parity テストが固定する。
117
+ *
118
+ * **写しがこのファイルに 2 つあったのを 1 つに寄せた(#398)。** ビューアは infra を
119
+ * import できないので写し自体は無くせないが、**同じ判定を 2 度書かない**。
120
+ */
121
+ function pendingIsSpaceRef(v) {
122
+ return /^spaces\/[A-Za-z0-9_-]+$/.test(String(v == null ? "" : v));
105
123
  }
106
124
  function pendingGoogleChatSpaceRef(input) {
107
125
  const s = String(input == null ? "" : input).trim();
108
126
  if (!s || s.length > 500)
109
127
  return null;
128
+ // 素の `spaces/<ID>`(#389)。URL ではないのでホスト検査より前に見る
129
+ if (pendingIsSpaceRef(s))
130
+ return s;
110
131
  if (!/^https?:\/\/(?:chat|mail)\.google\.com\//i.test(s))
111
132
  return null;
112
- if (/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?dm\//i.test(s) ||
133
+ if (/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?(?:dm|app\/dm)\//i.test(s) ||
113
134
  /^https?:\/\/mail\.google\.com\/[^#]*#chat\/dm\//i.test(s)) {
114
135
  return null;
115
136
  }
116
- const m = s.match(/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?room\/([^/?#]+)/i) ||
137
+ const m = s.match(/^https?:\/\/chat\.google\.com\/(?:u\/\d+\/)?(?:room|app\/(?:chat|join|management))\/([^/?#]+)/i) ||
117
138
  s.match(/^https?:\/\/mail\.google\.com\/(?:chat|mail)\/u\/\d+\/[^#]*#chat\/space\/([^/?#]+)/i);
118
139
  if (!m)
119
140
  return null;
120
141
  const ref = "spaces/" + m[1];
121
- return /^spaces\/[A-Za-z0-9_-]+$/.test(ref) ? ref : null;
142
+ return pendingIsSpaceRef(ref) ? ref : null;
122
143
  }
123
144
  function pendingRepo(input) {
124
145
  let s = String(input == null ? "" : input).trim();
@@ -171,14 +192,35 @@ function backlogTargetOf(input) {
171
192
  return num.test(view[1]) ? null : { domain: domain, projectKey: view[1].toUpperCase() };
172
193
  return null;
173
194
  }
195
+ /**
196
+ * Slack の team_id の形。**サーバの正本(shared/secret-names.mjs の `isSlackTeamId`)の写し**で、
197
+ * ずれは parity テストが固定する。
198
+ *
199
+ * 🔴 **狭くしないこと。** `T…` だけに絞ると **Enterprise Grid の `E…` を画面が弾き**、
200
+ * その組織のチャンネルだけ仮置きの行に札が出ない(サーバは受けているのに画面が捨てる形)。
201
+ */
202
+ function pendingIsSlackTeamId(v) {
203
+ return /^[TE][A-Z0-9]{6,20}$/.test(String(v == null ? "" : v));
204
+ }
174
205
  /** Backlog の接続先の鍵(render.ts の issuesRefOf と同形: ドメイン小文字 / キー大文字) */
175
206
  function pendingIssuesRef(domain, projectKey) {
176
207
  return String(domain == null ? "" : domain).toLowerCase() + "/" + String(projectKey == null ? "" : projectKey).toUpperCase();
177
208
  }
178
209
  // ---- エントリを作る(op → kind / action / targetId の対応表はここ 1 つ) ------------
179
- /** 表示名の整形(サーバの cleanLabel と同じ考え方: 制御文字を落とし、長すぎれば切る) */
210
+ /**
211
+ * 表示名の整形(サーバの cleanLabel の写し: 制御文字を落とし、連続空白を1つに畳み、
212
+ * 長すぎれば切る)。**空なら空文字**——サーバは null を返すが、画面側は `|| id` の
213
+ * 落とし先があるので文字列のまま扱う(parity テストは `|| null` にして突き合わせる)。
214
+ *
215
+ * 🔴 **連続空白の圧縮を落とさないこと(#391)。** サーバだけが畳むと、名前に連続空白を
216
+ * 含むチャンネルの update 仮置きが pendingOpLiveAgrees の名前比較で永久に同意せず、
217
+ * 24 時間(TTL)残り続ける。写しのずれはこの1行だけで起きる。
218
+ */
180
219
  function pendingLabel(s, max) {
181
- const v = String(s == null ? "" : s).replace(/[\u0000-\u001f\u007f]/g, " ").trim();
220
+ const v = String(s == null ? "" : s)
221
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
222
+ .replace(/\s+/g, " ")
223
+ .trim();
182
224
  const lim = max || 120;
183
225
  return v.length > lim ? v.slice(0, lim) : v;
184
226
  }
@@ -218,6 +260,15 @@ function pendingOpOf(payload, ctx) {
218
260
  const data = { name: pendingLabel(payload.name) || id };
219
261
  if (payload.workspace)
220
262
  data.workspace = String(payload.workspace);
263
+ // **どの組織のチャンネルかは、サーバの実測を応答から受け取る(#391)。**
264
+ // 画面はワークスペースを送らない(プルダウンは 2026-08-20 に廃止)ので、
265
+ // これが無いと仮置きの行にだけ組織の札が出ない=本物の行と見た目が食い違う。
266
+ // 🔴 **文字列 1 欄で運ぶ**("unknown" / "internal" / team_id)。真偽値を増やすと
267
+ // sanitizePendingOps の許可リストの例外が増え、篩が篩の意味を失う。
268
+ // 応答に無ければ載せない——載せないことが「まだ分からない」の表明になる
269
+ const org = ctx.response && typeof ctx.response.slackOrg === "string" ? ctx.response.slackOrg : "";
270
+ if (org)
271
+ data.slackOrg = org;
221
272
  return make("slack", "add", id, data);
222
273
  }
223
274
  case "remove-channel":
@@ -303,7 +354,7 @@ function sanitizePendingOps(raw, projectKey) {
303
354
  const src = e.payload && typeof e.payload === "object" ? e.payload : {};
304
355
  // 通してよい payload の欄(葉は文字列か、notify だけ真偽値)。関数の中に置くのは、
305
356
  // クライアントへ toString() で流し込んだ先でも同じ一覧を参照できるようにするため
306
- const fields = ["name", "workspace", "notify", "domain", "projectKey", "type"];
357
+ const fields = ["name", "workspace", "notify", "domain", "projectKey", "type", "slackOrg"];
307
358
  for (const f of fields) {
308
359
  if (!Object.prototype.hasOwnProperty.call(src, f))
309
360
  continue;
@@ -387,8 +438,46 @@ function pendingSiteTimeMs(s) {
387
438
  return Date.parse(s.replace(" UTC", "Z").replace(" ", "T"));
388
439
  }
389
440
  /**
390
- * 本物の一覧の世代が追いついたか。**判定の形はアイコン(iconPendingStale)・トゥームストーンと同じ**
391
- * ——いま画面が持っている世代が、保存時刻+余裕 と 保存時に見ていた世代+余裕 の両方より新しい。
441
+ * **「操作より後のビルドが来たか」の唯一の判定(#391)。**
442
+ *
443
+ * 削除のトゥームストーン・アイコンの反映待ち・操作タブの仮置きは、どれも「押した結果が
444
+ * 次のビルドで本物になるまでブラウザが覚えておく」という同じ性質の待ちで、破棄の条件も
445
+ * 同じだった。**同じものが 3 実装あると、片方だけ直したときに挙動が割れる**(値が同じ
446
+ * うちは誰も気づかない)ので、ここ 1 つに寄せてある。
447
+ *
448
+ * 判定: いま画面が持っている世代(nowMs)が、**押した時刻+余裕**と**押したとき見ていた
449
+ * 世代+余裕**の両方より新しい。generatedAt はコミット時点ではない——押す前に始まった
450
+ * ビルドが後から完成すると、世代は押した時刻より新しいのに操作は入っていない。
451
+ * ビルドの所要時間とクライアント時計のずれを見て余裕(既定 30 分)を取る。
452
+ *
453
+ * **主軸はサーバ時刻同士の比較**(現行の世代と、押した画面の gen0)。クライアントの時計が
454
+ * 大きく狂っていても機能が丸ごとは死なない(進んだ時計なら破棄が遅れるだけ)。
455
+ * gen0 を持たない古い形式のエントリは at だけで見る(後方互換・安全側)。
456
+ * nowMs が読めない(NaN)ときは false =**隠し続ける/残し続ける側に倒す**(嘘の再表示より安全)。
457
+ *
458
+ * @param at 押した時刻(ms・クライアント時計)
459
+ * @param gen0 押した画面の世代(generatedAt の文字列)。読めなければ at だけで見る
460
+ * @param nowMs いまの世代(ms)
461
+ *
462
+ * ⚠ 余裕は引数にしない。3 経路とも 30 分で、可変にすると**呼び出しが 1 つも無い引数**が
463
+ * 残る(壊しても何も起きない=変異検証の網から静かに漏れる形)。
464
+ */
465
+ function pendingStale(at, gen0, nowMs) {
466
+ const margin = PENDING_OPS_MARGIN_MS;
467
+ if (typeof nowMs !== "number" || isNaN(nowMs))
468
+ return false;
469
+ if (typeof at !== "number" || !isFinite(at))
470
+ return false;
471
+ if (!(nowMs > at + margin))
472
+ return false;
473
+ const g = pendingSiteTimeMs(gen0);
474
+ if (isNaN(g))
475
+ return true;
476
+ return nowMs > g + margin;
477
+ }
478
+ /**
479
+ * 本物の一覧の世代が追いついたか。**判定の実体は pendingStale**(トゥームストーン・
480
+ * アイコンの反映待ちと同じもの・#391)。
392
481
  *
393
482
  * 🔴 **世代は「一覧の下地」のもの(fleet-status の generatedAt)を渡すこと。** ページの
394
483
  * generatedAt(SITE.generatedAt)は記録の追加でも進むが、一覧の下地は夜間の自己チェックの
@@ -396,13 +485,9 @@ function pendingSiteTimeMs(s) {
396
485
  * 生成時刻はコミット時点ではない(保存前に始まった実行が後から完成する)ので余裕を取る。
397
486
  */
398
487
  function pendingOpBuildCaughtUp(e, siteNowMs) {
399
- if (!e || typeof siteNowMs !== "number" || isNaN(siteNowMs))
488
+ if (!e)
400
489
  return false;
401
- const byAt = siteNowMs > e.committedAt + PENDING_OPS_MARGIN_MS;
402
- const gen0 = pendingSiteTimeMs(e.baseBuildId);
403
- if (isNaN(gen0))
404
- return byAt;
405
- return byAt && siteNowMs > gen0 + PENDING_OPS_MARGIN_MS;
490
+ return pendingStale(e.committedAt, e.baseBuildId, siteNowMs);
406
491
  }
407
492
  /** registeredNow の行が、このエントリの対象か */
408
493
  function pendingLiveRowMatches(kind, r, targetId) {
@@ -443,8 +528,22 @@ function pendingOpLiveAgrees(e, live, liveAt) {
443
528
  found = r;
444
529
  break;
445
530
  }
446
- if (e.action === "remove")
447
- return !found;
531
+ if (e.action === "remove") {
532
+ if (found)
533
+ return false;
534
+ // 🔴 **github は「読み取り停止中」(exclude)の行が live の dev に載らない(#391)。**
535
+ // dev だけを見ると、exclude された行の remove は**保存が効く前から**「無い=同意」に
536
+ // なり、5 分で仮置きが捨てられて、まとめ表の行が次のビルドまで戻る(点滅する)。
537
+ // devExcluded を返すサーバなら、そちらにも無いことまで確かめてから同意する。
538
+ // 返さない古いサーバでは従来どおり(dev だけ)——判断材料が無いのに厳しくすると、
539
+ // 普通のリポジトリの解除まで 24 時間残る
540
+ if (e.kind === "github" && Array.isArray(live.devExcluded)) {
541
+ for (const r of live.devExcluded)
542
+ if (pendingLiveRowMatches(e.kind, r, e.targetId))
543
+ return false;
544
+ }
545
+ return true;
546
+ }
448
547
  if (e.action === "add")
449
548
  return !!found;
450
549
  if (!found)
@@ -538,6 +637,24 @@ function pendingExternalRow(e) {
538
637
  row.workspace = p.workspace;
539
638
  if (p.notify === true)
540
639
  row.notify = true;
640
+ // **サーバが実測した組織を、本物の行と同じ欄に展開する(#391)。**
641
+ // 展開できたときだけ pendingOrg を立てる——立っていない行に札を出すと、
642
+ // 測れていないのに「クラスメソッド」と名乗る(画面側の既定がそれなので)
643
+ // 値は 3 通りだけ通す("unknown" / "internal" / team_id)。**"internal" を team_id として
644
+ // 札に出さない**ための門で、形の検査はサーバの正本(pendingIsSlackTeamId)と同じにする
645
+ // ——狭くすると Enterprise Grid(E…)の組織だけ札が出ない
646
+ const org = typeof p.slackOrg === "string" ? p.slackOrg : "";
647
+ if (org === "unknown") {
648
+ row.pendingOrg = true;
649
+ row.orgUnknown = true;
650
+ }
651
+ else if (org === "internal") {
652
+ row.pendingOrg = true;
653
+ }
654
+ else if (pendingIsSlackTeamId(org)) {
655
+ row.pendingOrg = true;
656
+ row.workspace = org;
657
+ }
541
658
  }
542
659
  else if (e.kind === "google-chat") {
543
660
  row.type = "google-chat";
@@ -602,6 +719,12 @@ function applyPendingOps(view, list) {
602
719
  // dev-sources.json 側にも宣言された行(both / explicit)は channels.json から消しても
603
720
  // エンジンがまだ読む——消すと「読まれているのに画面に無い」逆向きの嘘になり、
604
721
  // live が追いついた後に復活する(設計レビュー②の中5)。そういう行は従来どおり取り消し線のまま
722
+ //
723
+ // **「読み取り停止中」(goldState: "excluded")の GitHub 行も、押したら消す(#391)。**
724
+ // サーバの applyRemoveGithubSource は sources と exclude の両方から実際に消すので、
725
+ // 保存は完全に効いている。ここで守ると「押しても何も起きない」=保存が失敗したのと
726
+ // 区別が付かない。消えたままにする仕掛けは 2 つ: 同意の判定(pendingOpLiveAgrees が
727
+ // devExcluded も見る)と、引き算(subtractRemovedRegistered が devExcluded で判断する)
605
728
  const removable = function (s) {
606
729
  return pendingExternalMatches(e.kind, s, e.targetId) && (e.kind !== "slack" || s.origin === "derived");
607
730
  };