@unson/brainbase-mcp 0.40.0 → 0.41.1

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.
@@ -296,7 +296,7 @@ export function renderKnowledgeDestination(root, model = {}, callbacks = {}) {
296
296
  section.append(makeElement('p', { className: 'knowledge-inline-status', text: '保存先を読み込んでいます。', attrs: { role: 'status' } }));
297
297
  }
298
298
  if (model.state === 'conflict') {
299
- section.append(statusNotice({ state: 'conflict', detail: model.message || '別の変更が先に保存されました。入力は保持しています。' }, { retry: Boolean(callbacks.onReload), onRetry: callbacks.onReload }));
299
+ section.append(statusNotice(model, { retry: Boolean(callbacks.onReload), onRetry: callbacks.onReload, retryLabel: '最新版を確認' }));
300
300
  } else if (['permission_denied', 'error_retryable', 'unknown'].includes(model.state)) {
301
301
  section.append(statusNotice(model, { retry: Boolean(callbacks.onReload), onRetry: callbacks.onReload }));
302
302
  } else if (model.state === 'verified') {
@@ -417,13 +417,97 @@ export function normalizeKnowledgeCollection(payload) {
417
417
  };
418
418
  }
419
419
 
420
+ function isRecord(value) {
421
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
422
+ }
423
+
424
+ function conflictMetadataFrom(value) {
425
+ if (!isRecord(value)) return {};
426
+ // A request body or locally retained draft is not evidence of the server's
427
+ // current version. Only explicit latest/current fields from the response
428
+ // may be rendered as the latest value. This keeps a submitted revision
429
+ // from being relabelled as the server's current revision after a 409.
430
+ const sources = [
431
+ value,
432
+ value.details,
433
+ value.conflict,
434
+ value.payload,
435
+ value.latest,
436
+ value.current,
437
+ ].filter(isRecord);
438
+ const read = (...keys) => {
439
+ for (const source of sources) {
440
+ const found = fieldValue(source, ...keys);
441
+ if (found !== undefined && found !== null && optionalText(found).trim() !== '') return found;
442
+ }
443
+ return undefined;
444
+ };
445
+ const readNestedCurrent = (key) => {
446
+ for (const source of [value.latest, value.current]) {
447
+ if (!isRecord(source)) continue;
448
+ const found = fieldValue(source, key);
449
+ if (found !== undefined && found !== null && optionalText(found).trim() !== '') return found;
450
+ }
451
+ return undefined;
452
+ };
453
+ const latestVersion = read('latest_version', 'latestVersion', 'current_version', 'currentVersion') ?? readNestedCurrent('version');
454
+ const latestRevision = read('latest_revision', 'latestRevision', 'current_revision', 'currentRevision') ?? readNestedCurrent('revision');
455
+ const expectedVersion = read('expected_version', 'expectedVersion');
456
+ const expectedRevision = read('expected_revision', 'expectedRevision');
457
+ const reason = read('conflict_reason', 'conflictReason', 'reason', 'detail', 'message');
458
+ const result = {};
459
+ if (latestVersion !== undefined) result.latest_version = latestVersion;
460
+ if (latestRevision !== undefined) result.latest_revision = latestRevision;
461
+ if (expectedVersion !== undefined) result.expected_version = expectedVersion;
462
+ if (expectedRevision !== undefined) result.expected_revision = expectedRevision;
463
+ if (reason !== undefined && optionalText(reason).trim() !== optionalText(value.code ?? value.message).trim()) result.conflict_reason = reason;
464
+ return result;
465
+ }
466
+
467
+ function draftRecordFrom(value) {
468
+ if (!isRecord(value)) return null;
469
+ if (isRecord(value.draft)) return value.draft;
470
+ if (isRecord(value.data?.draft)) return value.data.draft;
471
+ if (isRecord(value.data)) return value.data;
472
+ return value;
473
+ }
474
+
475
+ function draftReadbackFrom(value, expectedDraftId = null) {
476
+ const draft = draftRecordFrom(value);
477
+ if (!draft) return { valid: false, reason: 'payload_missing' };
478
+ const draftId = fieldValue(draft, 'draft_id', 'draftId', 'id');
479
+ if (expectedDraftId === null || expectedDraftId === undefined || optionalText(expectedDraftId).trim() === '') return { valid: false, reason: 'expected_id_missing', draftId };
480
+ if (draftId === undefined || draftId === null || optionalText(draftId).trim() === '') return { valid: false, reason: 'response_id_missing', draftId: null };
481
+ if (String(draftId) !== String(expectedDraftId)) return { valid: false, reason: 'response_id_mismatch', draftId };
482
+ const revision = fieldValue(draft, 'revision', 'version');
483
+ const content = fieldValue(draft, 'content', 'input_text', 'text');
484
+ const source = fieldValue(draft, 'source', 'provenance');
485
+ return {
486
+ valid: true,
487
+ draft_id: String(draftId),
488
+ revision: revision === undefined || revision === null || optionalText(revision).trim() === '' ? null : revision,
489
+ ...(content !== undefined && content !== null ? { content: String(content) } : {}),
490
+ ...(isRecord(source) ? { source } : {}),
491
+ };
492
+ }
493
+
494
+
420
495
  function errorFromPayload(payload, status = null) {
421
- const source = payload && typeof payload === 'object' ? payload.error ?? payload : {};
422
- const code = optionalText(fieldValue(source, 'code', 'error_code', 'type')) || (status === 403 ? 'permission_denied' : 'unknown_upstream_error');
496
+ const body = isRecord(payload) ? payload : {};
497
+ const source = isRecord(body.error) ? body.error : body;
498
+ const rawCode = optionalText(fieldValue(source, 'code', 'error_code', 'type')).trim();
499
+ const rawState = optionalText(fieldValue(source, 'state', 'status')).trim().toLowerCase();
500
+ const permissionFailure = Number(status) === 403
501
+ || ['forbidden', 'permission_denied', 'permission_insufficient', 'not_allowed', 'read_only_permission_denied', 'permission_decision_denied'].includes(rawCode.toLowerCase())
502
+ || ['forbidden', 'permission_denied', 'permission_insufficient', 'not_allowed', 'read_only_permission_denied', 'permission_decision_denied'].includes(rawState);
503
+ const code = permissionFailure ? 'permission_denied' : rawCode || 'unknown_upstream_error';
423
504
  const error = new Error(code);
424
505
  error.code = code;
425
- error.status = status ?? fieldValue(source, 'status', 'http_status');
506
+ error.status = status ?? fieldValue(source, 'status', 'http_status') ?? fieldValue(body, 'status', 'http_status');
426
507
  error.detail = fieldValue(source, 'message', 'detail', 'reason');
508
+ error.next_action = fieldValue(source, 'next_action', 'nextAction') ?? fieldValue(body, 'next_action', 'nextAction');
509
+ error.payload = body;
510
+ Object.assign(error, conflictMetadataFrom({ ...body, ...source, code, payload: body, details: source.details ?? body.details, conflict: source.conflict ?? body.conflict }));
427
511
  return error;
428
512
  }
429
513
 
@@ -443,6 +527,25 @@ export async function callKnowledgeApi(api, path, init = {}) {
443
527
  return await unwrapResponse(await api(path, init));
444
528
  } catch (caught) {
445
529
  if (caught?.code) throw caught;
530
+ const status = caught?.status ?? caught?.response?.status ?? null;
531
+ const rawPayload = caught?.payload
532
+ ?? caught?.response?.data
533
+ ?? caught?.response?.payload
534
+ ?? caught?.body;
535
+ const hasStatus = status !== null && status !== undefined;
536
+ if (hasStatus || rawPayload !== undefined) {
537
+ const payload = isRecord(rawPayload)
538
+ ? rawPayload
539
+ : { message: caught instanceof Error ? caught.message : String(caught) };
540
+ const error = errorFromPayload(payload, status);
541
+ if (!error.detail && caught instanceof Error && caught.message) error.detail = caught.message;
542
+ // ORG apiMutation throws a plain Error with status/payload but no code.
543
+ // Keep the original object so callers can inspect the upstream failure
544
+ // while the normalized error preserves conflict/permission metadata.
545
+ error.cause = caught;
546
+ if (rawPayload !== undefined) error.payload = rawPayload;
547
+ throw error;
548
+ }
446
549
  const error = new Error(caught instanceof Error ? caught.message : String(caught));
447
550
  error.code = 'unknown_upstream_error';
448
551
  error.cause = caught;
@@ -504,24 +607,52 @@ function operationKey(name, id, version, body = {}) {
504
607
 
505
608
  function apiErrorText(error) {
506
609
  const code = optionalText(error?.code || error?.message) || 'unknown_upstream_error';
610
+ if (isVersionConflict(error)) return '保存先の最新版と競合しました。';
611
+ const reason = optionalText(error?.detail).trim();
612
+ const nextAction = optionalText(error?.next_action ?? error?.nextAction).trim();
613
+ const appendNextAction = (message) => `${message}${nextAction ? ` 次の操作: ${nextAction}` : ''}`;
507
614
  const messages = {
508
615
  authentication_required: 'ログインが必要です。セッションを確認してください。',
509
616
  project_not_allowed: 'このプロジェクトを確認する権限がありません。',
510
617
  knowledge_project_not_accessible: 'このプロジェクトの知識を確認する権限がありません。',
511
- permission_denied: '知識の変更権限がありません。現在の役割では実行できません。',
512
- forbidden: '知識の変更権限がありません。現在の役割では実行できません。',
513
- version_conflict: '最新の版と競合しました。入力内容を残したまま、最新情報を再取得してください。',
618
+ permission_denied: 'この役割では対象範囲の知識を確認できません。',
619
+ forbidden: 'この役割では対象範囲の知識を確認できません。',
620
+ version_conflict: '保存先の最新版と競合しました。',
514
621
  readback_mismatch: '保存後の正本確認が一致しませんでした。未完了として再確認してください。',
515
622
  upstream_unavailable: '知識APIに接続できません。入力を保持して再試行してください。',
516
623
  unknown_upstream_error: '知識APIの結果を確認できません。入力を保持して再試行してください。',
517
624
  api_unavailable: '知識APIが未接続です。接続を確認してください。',
518
625
  project_context_missing: 'プロジェクトが選択されていません。',
519
626
  };
520
- return messages[code] ?? `知識APIを確認できません(${code})。入力を保持して再試行してください。`;
627
+ const message = messages[code] ?? `知識APIを確認できません(${code})。入力を保持して再試行してください。`;
628
+ if (code === 'permission_denied' || code === 'forbidden') return appendNextAction(reason ? `${message} ${reason}` : message);
629
+ return appendNextAction(message);
521
630
  }
522
631
 
523
632
  function isVersionConflict(error) {
524
- return ['version_conflict', 'knowledge_draft_revision_conflict', 'knowledge_lifecycle_version_conflict', 'knowledge_save_idempotency_conflict', 'knowledge_draft_not_editable'].includes(error?.code);
633
+ return Number(error?.status) === 409
634
+ || ['version_conflict', 'knowledge_draft_revision_conflict', 'knowledge_lifecycle_version_conflict', 'knowledge_save_idempotency_conflict', 'knowledge_draft_not_editable', 'destination_revision_conflict'].includes(error?.code);
635
+ }
636
+
637
+ function conflictNoticeVersion(model) {
638
+ const metadata = conflictMetadataFrom(model);
639
+ const latest = metadata.latest_version ?? metadata.latest_revision;
640
+ const expected = metadata.expected_version ?? metadata.expected_revision;
641
+ return {
642
+ latest: latest === undefined ? null : latest,
643
+ expected: expected === undefined ? null : expected,
644
+ reason: metadata.conflict_reason,
645
+ };
646
+ }
647
+
648
+ function pendingDraftUpdateReadyForReapply(pending) {
649
+ if (!isRecord(pending)) return false;
650
+ const draftId = optionalText(pending.draftId ?? pending.draft_id).trim();
651
+ const revision = optionalText(pending.latest_revision).trim();
652
+ const latest = pending.latest_draft_metadata;
653
+ const latestDraftId = optionalText(latest?.draft_id ?? latest?.draftId).trim();
654
+ const latestRevision = optionalText(latest?.revision).trim();
655
+ return Boolean(draftId && revision && latestDraftId && latestDraftId === draftId && latestRevision === revision);
525
656
  }
526
657
 
527
658
  function statusNotice(model, options = {}) {
@@ -531,8 +662,48 @@ function statusNotice(model, options = {}) {
531
662
  const title = stateLabel(state);
532
663
  const detail = model?.message || model?.error || model?.reason;
533
664
  append(notice, makeElement('strong', { text: title }), makeElement('span', { text: detail || options.detail || '' }));
665
+ if (state === 'conflict') {
666
+ const conflict = conflictNoticeVersion(model);
667
+ if (conflict.reason && conflict.reason !== detail) notice.append(makeElement('span', { className: 'knowledge-conflict-detail', text: `競合理由: ${text(conflict.reason)}` }));
668
+ if (conflict.latest !== null) notice.append(makeElement('span', { className: 'knowledge-conflict-detail', text: `最新版: 版 ${text(conflict.latest)}` }));
669
+ if (conflict.expected !== null) notice.append(makeElement('span', { className: 'knowledge-conflict-detail', text: `送信時の版: 版 ${text(conflict.expected)}` }));
670
+ notice.append(makeElement('span', { className: 'knowledge-conflict-detail', text: '入力と出典は保持しています。最新版を確認してから、必要なら再適用してください。' }));
671
+ const pending = model?.pending_draft_update;
672
+ const latest = pending?.latest_draft_metadata;
673
+ const localInput = pending?.local_input_text;
674
+ if (latest && (latest.content !== undefined || localInput !== undefined)) {
675
+ const comparison = makeElement('div', { className: 'knowledge-comparison knowledge-conflict-comparison' });
676
+ const latestColumn = makeElement('section', { className: 'knowledge-source-column' });
677
+ append(latestColumn,
678
+ makeElement('h4', { text: `確認した最新版${latest.revision !== undefined && latest.revision !== null ? `(版 ${text(latest.revision)})` : ''}` }),
679
+ makeElement('p', { className: 'knowledge-long-text', text: latest.content !== undefined ? latest.content : '最新版の本文を確認できません。' }),
680
+ );
681
+ const latestSource = latest.source;
682
+ const latestSourceLabel = latestSource && typeof latestSource === 'object'
683
+ ? [sourceKindLabel(latestSource.kind), sourcePointer(latestSource)].filter((value) => optionalText(value).trim()).join(' / ')
684
+ : optionalText(latestSource).trim();
685
+ if (latestSourceLabel) latestColumn.append(makeElement('p', { className: 'knowledge-conflict-detail', text: `確認した最新版の出典: ${latestSourceLabel}` }));
686
+ const localColumn = makeElement('section', { className: 'knowledge-proposal-column' });
687
+ append(localColumn,
688
+ makeElement('h4', { text: '入力中の原文(保持)' }),
689
+ makeElement('p', { className: 'knowledge-long-text', text: localInput !== undefined ? localInput : '入力中の原文を確認できません。' }),
690
+ );
691
+ const localSource = pending.local_source;
692
+ const sourceLabelText = localSource && typeof localSource === 'object'
693
+ ? [sourceKindLabel(localSource.kind), sourcePointer(localSource)].filter((value) => optionalText(value).trim()).join(' / ')
694
+ : optionalText(localSource).trim();
695
+ if (sourceLabelText) localColumn.append(makeElement('p', { className: 'knowledge-conflict-detail', text: `保持中の出典: ${sourceLabelText}` }));
696
+ append(comparison, latestColumn, localColumn);
697
+ notice.append(comparison);
698
+ }
699
+ if (pendingDraftUpdateReadyForReapply(pending) && typeof options.onReapply === 'function') {
700
+ const reapply = makeElement('button', { className: 'knowledge-button primary', text: options.reapplyLabel ?? '保持した内容を再適用して保存', attrs: { type: 'button' } });
701
+ reapply.addEventListener('click', () => options.onReapply());
702
+ notice.append(reapply);
703
+ }
704
+ }
534
705
  if (options.retry && typeof options.onRetry === 'function') {
535
- const retry = makeElement('button', { className: 'knowledge-button secondary', text: '再試行', attrs: { type: 'button' } });
706
+ const retry = makeElement('button', { className: 'knowledge-button secondary', text: options.retryLabel ?? '再試行', attrs: { type: 'button' } });
536
707
  retry.addEventListener('click', () => options.onRetry());
537
708
  notice.append(retry);
538
709
  }
@@ -616,12 +787,14 @@ export function renderKnowledgeList(root, model = {}, callbacks = {}) {
616
787
  const searchedScope = Array.isArray(collection.searched_scope) && collection.searched_scope.length
617
788
  ? collection.searched_scope.map(scopeLabel).join(' / ')
618
789
  : null;
790
+ const state = normaliseStatus(collection.state);
791
+ const countKnown = Array.isArray(collection.records)
792
+ && ['ready', 'results', EMPTY].includes(state);
619
793
  const section = makeElement('section', { className: 'knowledge-list-panel', attrs: { 'aria-labelledby': 'knowledge-list-title' } });
620
794
  const header = makeElement('div', { className: 'knowledge-panel-head' });
621
- append(header, makeElement('div', {},), makeElement('span', { className: 'knowledge-count', text: Array.isArray(collection.records) ? `${collection.records.length}件` : UNKNOWN }));
795
+ append(header, makeElement('div', {},), makeElement('span', { className: 'knowledge-count', text: countKnown ? `${collection.records.length}件` : UNKNOWN }));
622
796
  header.firstChild.append(makeElement('h3', { text: '知識・判断', attrs: { id: 'knowledge-list-title' } }), makeElement('p', { text: searchedScope ? `検索範囲: ${searchedScope}` : '検索範囲: 未確認' }));
623
797
  section.append(header);
624
- const state = normaliseStatus(collection.state);
625
798
  if (state === 'loading') {
626
799
  const skeleton = makeElement('div', { className: 'knowledge-list-rows', attrs: { 'aria-label': '知識を読み込み中' } });
627
800
  for (let index = 0; index < 4; index += 1) skeleton.append(skeletonRow());
@@ -629,7 +802,13 @@ export function renderKnowledgeList(root, model = {}, callbacks = {}) {
629
802
  } else if (state === EMPTY) {
630
803
  section.append(makeElement('div', { className: 'knowledge-empty', text: 'この条件に一致する知識はありません。自然文から登録案を作成できます。' }));
631
804
  } else if (state === 'permission_denied') {
632
- section.append(statusNotice({ state, detail: '必要なプロジェクト権限を確認してください。' }, { retry: true, onRetry: callbacks.onRetry }));
805
+ // Keep the upstream reason and recovery action visible. Replacing the
806
+ // payload with a generic detail makes a denied scope look like an
807
+ // unexplained empty result and leaves the user without a next step.
808
+ const permissionModel = collection.message
809
+ ? collection
810
+ : { ...collection, message: '必要なプロジェクト権限を確認してください。' };
811
+ section.append(statusNotice(permissionModel, { retry: true, onRetry: callbacks.onRetry }));
633
812
  } else if (['error', 'error_retryable'].includes(state)) {
634
813
  section.append(statusNotice(collection, { retry: true, onRetry: callbacks.onRetry }));
635
814
  } else if (state === 'unknown' || !Array.isArray(collection.records)) {
@@ -765,18 +944,29 @@ function formField(labelText, control, hint = '') {
765
944
  export function renderDraftCapture(root, model = {}, callbacks = {}) {
766
945
  clear(root);
767
946
  const draft = model.draft ?? model;
947
+ const editLocked = callbacks.editLocked === true;
768
948
  const section = makeElement('section', { className: 'knowledge-capture-panel', attrs: { 'aria-labelledby': 'knowledge-capture-title' } });
769
949
  append(section, makeElement('span', { className: 'knowledge-eyebrow', text: '登録案を作る' }), makeElement('h3', { text: '自然文から未確定候補を作成', attrs: { id: 'knowledge-capture-title' } }), makeElement('p', { text: '入力した文章や許可資料から提案を作ります。提案は確認が終わるまで正本へ保存されません。' }));
770
950
  const form = makeElement('form', { className: 'knowledge-capture-form', attrs: { 'aria-busy': ['loading', 'saving'].includes(normaliseStatus(draft.state)) ? 'true' : 'false' } });
771
951
  const resumeId = makeElement('input', { className: 'knowledge-control', value: draft.resume_id ?? '', attrs: { name: 'resume_id', maxlength: '200', placeholder: '保存済み下書きID' } });
772
- const resume = makeElement('button', { className: 'knowledge-button secondary', text: '下書きを再開', attrs: { type: 'button' } });
773
- resume.addEventListener('click', () => callbacks.onResume?.(resumeId.value.trim()));
952
+ const resume = makeElement('button', { className: 'knowledge-button secondary', text: '下書きを再開', attrs: { type: 'button' }, disabled: editLocked });
953
+ resume.addEventListener('click', () => { if (!editLocked) callbacks.onResume?.(resumeId.value.trim()); });
774
954
  const resumeRow = makeElement('div', { className: 'knowledge-form-actions' });
775
955
  resumeRow.append(resume);
776
956
  const sourceText = optionalText(draft.input_text ?? draft.text ?? model.input_text);
777
957
  const input = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: sourceText, attrs: { name: 'input_text', rows: '8', maxlength: '20000', required: 'required', placeholder: '例: 顧客への初回提案では、まず利用目的と判断期限を確認する。' } });
778
958
  const sourcePointerInput = makeElement('input', { className: 'knowledge-control', value: draft.source?.pointer ?? draft.source_pointer ?? '', attrs: { name: 'source_pointer', maxlength: '2048', placeholder: '許可された資料のURLまたはrepo相対参照(任意)' } });
779
959
  const sourceKindInput = makeElement('input', { className: 'knowledge-control', value: draft.source?.kind ?? draft.source_kind ?? '', attrs: { name: 'source_kind', maxlength: '120', placeholder: '資料種別(任意)' } });
960
+ for (const control of [resumeId, input, sourcePointerInput, sourceKindInput]) control.disabled = editLocked;
961
+ const readDraftInput = () => ({
962
+ input_text: input.value,
963
+ source: { pointer: sourcePointerInput.value.trim(), kind: sourceKindInput.value.trim() },
964
+ });
965
+ for (const control of [input, sourcePointerInput, sourceKindInput]) {
966
+ const syncInput = () => { if (!editLocked) callbacks.onInput?.(readDraftInput()); };
967
+ control.addEventListener('input', syncInput);
968
+ control.addEventListener('change', syncInput);
969
+ }
780
970
  append(form,
781
971
  formField('保存済み下書きを再開', resumeId, '一覧APIはないため、保存時に表示された下書きIDを入力してください。'),
782
972
  resumeRow,
@@ -788,20 +978,17 @@ export function renderDraftCapture(root, model = {}, callbacks = {}) {
788
978
  if (savedId) form.append(makeElement('p', { className: 'knowledge-inline-status', text: `保存済み下書きID: ${savedId}${draft.revision ?? draft.version ? ` / 版: ${draft.revision ?? draft.version}` : ''}` }));
789
979
  if (savedId && draft.resume_url) form.append(makeElement('a', { className: 'knowledge-button secondary', text: 'この下書きの再開リンク', attrs: { href: draft.resume_url } }));
790
980
  const actions = makeElement('div', { className: 'knowledge-form-actions' });
791
- const submit = makeElement('button', { className: 'knowledge-button primary', text: normaliseStatus(draft.state) === 'saving' ? 'AIで整理中…' : 'AIで整理', attrs: { type: 'submit' }, disabled: normaliseStatus(draft.state) === 'saving' });
792
- const discard = makeElement('button', { className: 'knowledge-button secondary', text: '入力を破棄', attrs: { type: 'button' } });
981
+ const submit = makeElement('button', { className: 'knowledge-button primary', text: normaliseStatus(draft.state) === 'saving' ? 'AIで整理中…' : 'AIで整理', attrs: { type: 'submit' }, disabled: editLocked || normaliseStatus(draft.state) === 'saving' });
982
+ const discard = makeElement('button', { className: 'knowledge-button secondary', text: '入力を破棄', attrs: { type: 'button' }, disabled: editLocked });
793
983
  discard.addEventListener('click', () => callbacks.onDiscard?.());
794
984
  actions.append(submit, discard);
795
985
  form.append(actions);
796
- if (['error', 'error_retryable', 'permission_denied', 'conflict', 'unknown'].includes(normaliseStatus(draft.state))) form.append(statusNotice({ state: draft.state, detail: draft.message || draft.error }, { retry: Boolean(callbacks.onRetry), onRetry: callbacks.onRetry }));
986
+ if (['error', 'error_retryable', 'permission_denied', 'conflict', 'unknown'].includes(normaliseStatus(draft.state))) form.append(statusNotice(draft, { retry: !editLocked && Boolean(callbacks.onRetry), onRetry: editLocked ? null : callbacks.onRetry }));
797
987
  if (draft.state === 'saving') form.append(makeElement('p', { className: 'knowledge-inline-status', text: '候補生成の応答を待っています。入力は保持されます。', attrs: { role: 'status' } }));
798
988
  form.addEventListener('submit', (event) => {
799
989
  event.preventDefault();
800
990
  if (typeof form.reportValidity === 'function' && !form.reportValidity()) return;
801
- callbacks.onSubmit?.({
802
- input_text: input.value,
803
- source: { pointer: sourcePointerInput.value.trim(), kind: sourceKindInput.value.trim() },
804
- });
991
+ if (!editLocked) callbacks.onSubmit?.(readDraftInput());
805
992
  });
806
993
  section.append(form);
807
994
  root.append(section);
@@ -816,6 +1003,7 @@ function candidateShape(model) {
816
1003
  export function renderCandidateReview(root, model = {}, callbacks = {}) {
817
1004
  clear(root);
818
1005
  const candidate = candidateShape(model);
1006
+ const editLocked = callbacks.editLocked === true;
819
1007
  const section = makeElement('section', { className: 'knowledge-review-panel', attrs: { 'aria-labelledby': 'knowledge-review-title' } });
820
1008
  append(section, makeElement('span', { className: 'knowledge-eyebrow', text: '確認が必要です' }), makeElement('h3', { text: '未確定候補を確認', attrs: { id: 'knowledge-review-title' } }), makeElement('p', { text: '候補はAIの提案です。根拠のない責任者・事実を確定せず、原文と提案の差分を確認してください。' }));
821
1009
  if (model.canonical === false || model.persisted === false || model.readback?.state === 'proposal_only') {
@@ -875,17 +1063,20 @@ export function renderCandidateReview(root, model = {}, callbacks = {}) {
875
1063
  createLabel.append(create, makeElement('span', { text: '別の正本項目として作成' }));
876
1064
  const canonicalId = makeElement('input', { className: 'knowledge-control', value: candidate.canonical_id ?? model.canonical_id ?? '', attrs: { name: 'canonical_id', maxlength: '240', placeholder: '再利用する正本ID' } });
877
1065
  const canonicalVersion = makeElement('input', { className: 'knowledge-control', value: candidate.canonical_version ?? candidate.expected_version ?? model.canonical_version ?? '', attrs: { name: 'canonical_version', maxlength: '240', placeholder: '再利用する正本の現在版' } });
878
- for (const control of [type, title, summary, scope, decisionDomain, owner, applicability, relationType, relations, reuse, create, canonicalId, canonicalVersion]) control.disabled = !proposalApplied;
1066
+ for (const control of [type, title, summary, scope, decisionDomain, owner, applicability, relationType, relations, reuse, create, canonicalId, canonicalVersion]) control.disabled = !proposalApplied || editLocked;
879
1067
  append(form, makeElement('fieldset', { className: 'knowledge-choice-group' }));
880
1068
  form.lastChild.append(makeElement('legend', { text: '保存方法' }), reuseLabel, createLabel);
881
1069
  form.append(formField('再利用する正本', canonicalId, '既存の正本候補を再利用する場合は正本IDが必要です。'));
882
1070
  form.append(formField('再利用する正本の版', canonicalVersion, '並行更新を見逃さないよう、確認した現在版を指定します。'));
883
1071
  const actions = makeElement('div', { className: 'knowledge-form-actions' });
884
- const apply = makeElement('button', { className: 'knowledge-button secondary', text: proposalApplied ? '入力欄へ反映済み' : '入力欄に反映', attrs: { type: 'button' }, disabled: proposalApplied || !callbacks.onApply });
885
- apply.addEventListener('click', () => callbacks.onApply?.());
886
- const save = makeElement('button', { className: 'knowledge-button secondary', text: '下書き保存', attrs: { type: 'submit' }, disabled: !proposalApplied || !callbacks.onSave });
887
- const commit = makeElement('button', { className: 'knowledge-button primary', text: '確認して正本へ保存', attrs: { type: 'button' }, disabled: !callbacks.onCommit });
1072
+ const apply = makeElement('button', { className: 'knowledge-button secondary', text: proposalApplied ? '入力欄へ反映済み' : '入力欄に反映', attrs: { type: 'button' }, disabled: editLocked || proposalApplied || !callbacks.onApply });
1073
+ apply.addEventListener('click', () => { if (!editLocked) callbacks.onApply?.(); });
1074
+ const save = makeElement('button', { className: 'knowledge-button secondary', text: '下書き保存', attrs: { type: 'submit' }, disabled: editLocked || !proposalApplied || !callbacks.onSave });
1075
+ const commit = makeElement('button', { className: 'knowledge-button primary', text: '確認して正本へ保存', attrs: { type: 'button' }, disabled: editLocked || !callbacks.onCommit });
888
1076
  actions.append(apply, save, commit); form.append(actions);
1077
+ // The retry action is a recovery control, not an editor. Keep it available
1078
+ // while the fields and mutation buttons are locked so the user can finish a
1079
+ // same-draft readback without losing the local candidate edits.
889
1080
  if (model.state && !['candidate', 'ready'].includes(normaliseStatus(model.state))) form.append(statusNotice(model, { retry: Boolean(callbacks.onRetry), onRetry: callbacks.onRetry }));
890
1081
  const collect = () => ({
891
1082
  type: type.value,
@@ -909,8 +1100,8 @@ export function renderCandidateReview(root, model = {}, callbacks = {}) {
909
1100
  relationType.setCustomValidity?.('');
910
1101
  return collect();
911
1102
  };
912
- form.addEventListener('submit', (event) => { event.preventDefault(); if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; const values = collectValid(); if (values) callbacks.onSave?.(values); });
913
- commit.addEventListener('click', () => { const values = collectValid(); if (values) callbacks.onCommit?.(values); });
1103
+ form.addEventListener('submit', (event) => { event.preventDefault(); if (editLocked) return; if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; const values = collectValid(); if (values) callbacks.onSave?.(values); });
1104
+ commit.addEventListener('click', () => { if (editLocked) return; const values = collectValid(); if (values) callbacks.onCommit?.(values); });
914
1105
  proposal.append(form); comparison.append(original, proposal); section.append(comparison);
915
1106
  const evidence = asArray(model.evidence);
916
1107
  const exclusions = asArray(model.exclusions);
@@ -946,7 +1137,14 @@ export function renderCommitState(root, model = {}, callbacks = {}) {
946
1137
  retry.addEventListener('click', () => callbacks.onReadback()); section.append(retry);
947
1138
  }
948
1139
  } else if (state === 'verified') section.append(makeElement('p', { className: 'knowledge-success-copy', text: '正本と関係のreadbackが一致しました。検索反映の状態は別に確認します。', attrs: { role: 'status' } }));
949
- else if (state === 'conflict') section.append(statusNotice({ state, detail: '最新の版を取得して差分を確認してください。' }, { retry: Boolean(callbacks.onReadback), onRetry: callbacks.onReadback }));
1140
+ else if (state === 'conflict') section.append(statusNotice(model, {
1141
+ detail: '最新版を確認してから、必要なら再適用してください。',
1142
+ retry: Boolean(callbacks.onReadback),
1143
+ onRetry: callbacks.onReadback,
1144
+ retryLabel: '再試行(入力を保持)',
1145
+ onReapply: callbacks.onReapply,
1146
+ reapplyLabel: '保持した内容を再適用して保存',
1147
+ }));
950
1148
  else if (['error', 'error_retryable', 'permission_denied', 'unknown'].includes(state)) section.append(statusNotice(model, { retry: Boolean(callbacks.onReadback), onRetry: callbacks.onReadback }));
951
1149
  renderDefinitionList(section, [
952
1150
  ['正本ID', model.item_id ?? model.item?.id ?? UNKNOWN],
@@ -1263,7 +1461,10 @@ export function createKnowledgeOutcomeController(options = {}) {
1263
1461
  }
1264
1462
  header.append(nav); shell.append(header);
1265
1463
  const body = makeElement('div', { className: 'knowledge-outcome-body knowledge-workspace', attrs: { 'data-view': state.view } });
1266
- const primary = makeElement('main', { className: 'knowledge-outcome-primary knowledge-master', attrs: { 'aria-label': state.view === 'discovery' ? '知識の一覧' : '知識の操作' } });
1464
+ // The host application owns the page-level main landmark. Keeping the
1465
+ // outcome workspace as a labelled section avoids a duplicate main when
1466
+ // this module is mounted inside that page.
1467
+ const primary = makeElement('section', { className: 'knowledge-outcome-primary knowledge-master', attrs: { 'aria-label': state.view === 'discovery' ? '知識の一覧' : '知識の操作' } });
1267
1468
  const discovery = makeElement('section', { className: 'knowledge-discovery-view', hidden: state.view !== 'discovery', attrs: { 'aria-labelledby': 'knowledge-discovery-title' } });
1268
1469
  const discoveryHeader = makeElement('div', { className: 'knowledge-view-heading' });
1269
1470
  append(discoveryHeader,
@@ -1285,16 +1486,41 @@ export function createKnowledgeOutcomeController(options = {}) {
1285
1486
  captureHeading.firstChild.append(makeElement('h3', { id: 'knowledge-capture-workspace-title', text: '登録案を作る' }), makeElement('p', { text: '自然文を候補へ整理し、正本へ保存する前に内容と根拠を確認します。' }));
1286
1487
  captureWorkspace.append(captureHeading);
1287
1488
  const capture = makeElement('div', { className: 'knowledge-capture-form-region' });
1288
- renderDraftCapture(capture, state.draft, { onSubmit: createDraft, onResume: resumeDraft, onDiscard: discardDraft, onRetry: () => createDraft({ input_text: state.draft.input_text, source: state.draft.source }) });
1489
+ const pendingDraftUpdate = state.commit.pending_draft_update;
1490
+ const draftConflictNeedsRead = Boolean(pendingDraftUpdate && (pendingDraftUpdate.latest_revision === undefined || pendingDraftUpdate.latest_revision === null));
1491
+ const commitLocked = state.commit.state === 'saving' || Boolean(state.commit.pending_save) || draftConflictNeedsRead || ['verified', 'saved_unverified'].includes(state.commit.state);
1492
+ const candidateReadbackPending = state.candidate.candidate_readback_pending === true;
1493
+ const candidateInteractionLocked = state.candidate.state !== 'idle'
1494
+ && (commitLocked || Boolean(pendingDraftUpdate) || candidateReadbackPending || state.candidate.state === 'saving');
1495
+ renderDraftCapture(capture, state.draft, {
1496
+ onSubmit: createDraft,
1497
+ onResume: resumeDraft,
1498
+ onDiscard: discardDraft,
1499
+ onInput: (next) => {
1500
+ state.draft = { ...state.draft, ...next };
1501
+ if (state.candidate.state !== 'idle') state.candidate = { ...state.candidate, ...next };
1502
+ },
1503
+ editLocked: candidateInteractionLocked,
1504
+ onRetry: state.candidate.state === 'idle' && !candidateInteractionLocked
1505
+ ? () => createDraft({ input_text: state.draft.input_text, source: state.draft.source })
1506
+ : null,
1507
+ });
1289
1508
  captureWorkspace.append(capture);
1290
1509
  const review = makeElement('div', { className: 'knowledge-review-region', hidden: state.view !== 'capture' || state.candidate.state === 'idle' });
1291
1510
  if (state.candidate.state !== 'idle') {
1292
- const commitLocked = state.commit.state === 'saving' || Boolean(state.commit.pending_save) || ['verified', 'saved_unverified'].includes(state.commit.state);
1511
+ const candidateEditLocked = commitLocked || Boolean(pendingDraftUpdate) || candidateReadbackPending || state.candidate.state === 'saving';
1512
+ const candidateCommitLocked = candidateEditLocked || state.candidate.state === 'conflict';
1513
+ const persistedCandidateDraftId = state.candidate.draft_id ?? state.draft.draft_id ?? state.draft.id;
1293
1514
  renderCandidateReview(review, { ...state.candidate, decision_domains: state.authority.domains ?? [] }, {
1294
- onApply: commitLocked ? null : applyProposal,
1295
- onSave: commitLocked ? null : saveCandidate,
1296
- onCommit: commitLocked || !(state.candidate.draft_id ?? state.draft.id) ? null : commitCandidate,
1297
- onRetry: state.commit.pending_save ? retryPendingSave : () => createDraft({ input_text: state.draft.input_text, source: state.draft.source }),
1515
+ editLocked: candidateEditLocked,
1516
+ onApply: candidateEditLocked ? null : applyProposal,
1517
+ onSave: candidateEditLocked ? null : saveCandidate,
1518
+ onCommit: candidateCommitLocked || !(state.candidate.draft_id ?? state.draft.id) ? null : commitCandidate,
1519
+ onRetry: state.commit.pending_save
1520
+ ? retryPendingSave
1521
+ : persistedCandidateDraftId
1522
+ ? retryCandidateDraftReadback
1523
+ : () => createDraft({ input_text: state.draft.input_text, source: state.draft.source }),
1298
1524
  });
1299
1525
  }
1300
1526
  captureWorkspace.append(review);
@@ -1346,7 +1572,21 @@ export function createKnowledgeOutcomeController(options = {}) {
1346
1572
  const lifecycle = makeElement('div', { className: 'knowledge-inspector-lifecycle' });
1347
1573
  const detailUnavailable = ['loading', 'error', 'error_retryable', 'permission_denied', 'unknown'].includes(state.detail.state);
1348
1574
  if (state.selected && !detailUnavailable) renderLifecycle(lifecycle, state.selected, { canEdit: canManageKnowledge(session), ownerCandidates, revisionDraft: state.lifecycle.revisionDraft, supersessionDraft: state.lifecycle.supersessionDraft, supersessionCandidates: state.list.records, onRevision: reviseItem, onSupersede: supersedeItem, onRetire: retireItem }); detail.append(lifecycle);
1349
- if (state.commit.state !== 'idle') { const commit = makeElement('div'); renderCommitState(commit, state.commit, { onReadback: state.commit.pending_save ? retryPendingSave : readbackCommit }); detail.append(commit); }
1575
+ if (state.commit.state !== 'idle') {
1576
+ const commit = makeElement('div');
1577
+ const pendingDraftUpdate = state.commit.pending_draft_update;
1578
+ const draftConflictNeedsRead = Boolean(pendingDraftUpdate && (pendingDraftUpdate.latest_revision === undefined || pendingDraftUpdate.latest_revision === null));
1579
+ const onReadback = state.commit.pending_save || draftConflictNeedsRead
1580
+ ? retryPendingSave
1581
+ : state.commit.state === 'conflict' && pendingDraftUpdate
1582
+ ? null
1583
+ : readbackCommit;
1584
+ const onReapply = state.commit.state === 'conflict' && pendingDraftUpdateReadyForReapply(pendingDraftUpdate)
1585
+ ? reapplyPendingDraftUpdate
1586
+ : null;
1587
+ renderCommitState(commit, state.commit, { onReadback, onReapply });
1588
+ detail.append(commit);
1589
+ }
1350
1590
  body.append(detail); shell.append(body); root.append(shell); notify();
1351
1591
  return shell;
1352
1592
  }
@@ -1411,7 +1651,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1411
1651
  message: matches ? null : '保存後の再取得結果が保存先の契約と一致しません。入力内容は保持します。',
1412
1652
  };
1413
1653
  } catch (error) {
1414
- state.destination = { ...state.destination, state: isVersionConflict(error) || error.status === 409 ? 'conflict' : error.status === 403 ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, draft: { ...input } };
1654
+ state.destination = { ...state.destination, state: isVersionConflict(error) || error.status === 409 ? 'conflict' : error.status === 403 ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error), draft: { ...input } };
1415
1655
  }
1416
1656
  render(); return state.destination;
1417
1657
  }
@@ -1448,7 +1688,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1448
1688
  const proposed = payload?.proposal ?? {};
1449
1689
  state.draft = { ...state.draft, ...preserved, state: 'candidate' };
1450
1690
  state.candidate = { ...payload, candidate: proposed, input_text: preserved.input_text, source: preserved.source, state: 'candidate', proposal_applied: false, draft_id: null, version: null };
1451
- } catch (error) { state.draft = { ...state.draft, state: error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
1691
+ } catch (error) { state.draft = { ...state.draft, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) }; }
1452
1692
  render(); return state.candidate;
1453
1693
  }
1454
1694
 
@@ -1469,7 +1709,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1469
1709
  : error.code === 'knowledge_draft_not_found' || error.status === 404
1470
1710
  ? 'unknown'
1471
1711
  : error.status === 409 || isVersionConflict(error) ? 'conflict' : 'error_retryable';
1472
- state.draft = { ...state.draft, state: failureState, message: apiErrorText(error), error: error.code };
1712
+ state.draft = { ...state.draft, state: failureState, message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) };
1473
1713
  }
1474
1714
  render(); return state.candidate;
1475
1715
  }
@@ -1484,7 +1724,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1484
1724
  await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}/discard`), { method: 'POST', body: { revision }, key: operationKey('draft-discard', draftId, revision, { revision }) });
1485
1725
  state.draft = { state: 'idle', input_text: '' }; state.candidate = { state: 'idle' };
1486
1726
  } catch (error) {
1487
- state.draft = { ...preservedDraft, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', message: apiErrorText(error), error: error.code };
1727
+ state.draft = { ...preservedDraft, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) };
1488
1728
  state.candidate = preservedCandidate;
1489
1729
  }
1490
1730
  render(); return state.draft;
@@ -1510,6 +1750,11 @@ export function createKnowledgeOutcomeController(options = {}) {
1510
1750
  }
1511
1751
 
1512
1752
  async function saveCandidate(values) {
1753
+ if (state.candidate.state === 'saving'
1754
+ || state.candidate.candidate_readback_pending === true
1755
+ || state.commit.state === 'saving'
1756
+ || state.commit.pending_save
1757
+ || state.commit.pending_draft_update) return state.candidate;
1513
1758
  const draftId = state.candidate.draft_id ?? state.draft.id;
1514
1759
  if (!state.candidate.proposal_applied) { state.candidate = { ...state.candidate, state: 'unknown', message: 'AI提案を入力欄へ反映してから下書きを保存してください。' }; render(); return null; }
1515
1760
  const editedCandidate = { ...(state.candidate.candidate ?? {}), ...values };
@@ -1517,22 +1762,122 @@ export function createKnowledgeOutcomeController(options = {}) {
1517
1762
  try {
1518
1763
  const candidateBody = serializeCandidate ? serializeCandidate(values, state.candidate) : defaultCandidateBody(values, state.draft);
1519
1764
  const revision = state.candidate.version ?? state.draft.revision ?? state.draft.version;
1765
+ if (draftId && (revision === null || revision === undefined || optionalText(revision).trim() === '')) {
1766
+ state.candidate = {
1767
+ ...state.candidate,
1768
+ candidate: editedCandidate,
1769
+ state: 'conflict',
1770
+ candidate_readback_pending: true,
1771
+ version: null,
1772
+ latest_revision: null,
1773
+ message: '下書きの版を確認できないため、保存を停止しました。最新版を確認してから再試行してください。',
1774
+ };
1775
+ state.draft = { ...state.draft, state: 'conflict', id: draftId, draft_id: draftId };
1776
+ render(); return state.candidate;
1777
+ }
1520
1778
  const body = draftId ? { ...candidateBody, revision } : candidateBody;
1521
1779
  const path = draftId ? `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}` : KNOWLEDGE_ROUTES.drafts;
1522
1780
  const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, path), { method: draftId ? 'PATCH' : 'POST', body, key: operationKey(draftId ? 'draft-update' : 'draft', draftId ?? 'new', revision, body) });
1523
1781
  const draft = payload?.draft ?? payload?.data ?? payload;
1524
- state.candidate = { ...state.candidate, ...draft, candidate: { ...editedCandidate, ...(draft?.candidate ?? {}) }, state: 'candidate', proposal_applied: true, draft_id: draft?.draft_id ?? draft?.id ?? draftId, version: draft?.revision ?? draft?.version ?? state.candidate.version };
1782
+ state.candidate = { ...state.candidate, ...draft, candidate: { ...editedCandidate, ...(draft?.candidate ?? {}) }, state: 'candidate', proposal_applied: true, candidate_readback_pending: false, draft_id: draft?.draft_id ?? draft?.id ?? draftId, version: draft?.revision ?? draft?.version ?? state.candidate.version };
1525
1783
  const savedId = draft?.draft_id ?? draft?.id ?? draftId;
1526
1784
  state.draft = { ...state.draft, ...draft, id: savedId, draft_id: savedId, resume_url: resumeUrl(savedId), state: 'candidate' };
1527
- } catch (error) { state.candidate = { ...state.candidate, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
1785
+ } catch (error) { state.candidate = { ...state.candidate, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', candidate_readback_pending: Boolean(draftId), message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) }; }
1786
+ render(); return state.candidate;
1787
+ }
1788
+
1789
+ async function retryCandidateDraftReadback() {
1790
+ if (state.candidate.state === 'saving') return state.candidate;
1791
+ const draftId = state.candidate.draft_id ?? state.draft.draft_id ?? state.draft.id;
1792
+ if (!draftId) return createDraft({ input_text: state.draft.input_text, source: state.draft.source });
1793
+
1794
+ // Keep the user's candidate edits separate from the server response. The
1795
+ // response is used only to refresh the draft identity/version; replacing
1796
+ // the candidate here would turn a recoverable save conflict into a silent
1797
+ // loss of the title and summary the user just edited.
1798
+ const localCandidate = { ...(state.candidate.candidate ?? {}) };
1799
+ const localInput = state.draft.input_text ?? state.candidate.input_text ?? '';
1800
+ const localSource = state.draft.source ?? state.candidate.source ?? null;
1801
+ state.candidate = {
1802
+ ...state.candidate,
1803
+ state: 'saving',
1804
+ candidate_readback_pending: true,
1805
+ candidate: localCandidate,
1806
+ input_text: localInput,
1807
+ source: localSource,
1808
+ message: '下書きの最新版を確認しています。入力と提案は保持されます。',
1809
+ };
1810
+ render();
1811
+ try {
1812
+ const payload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}`));
1813
+ const latestDraft = draftReadbackFrom(payload, draftId);
1814
+ if (!latestDraft.valid) {
1815
+ state.candidate = {
1816
+ ...state.candidate,
1817
+ state: 'conflict',
1818
+ candidate_readback_pending: true,
1819
+ draft_id: draftId,
1820
+ version: null,
1821
+ latest_revision: null,
1822
+ candidate: localCandidate,
1823
+ input_text: localInput,
1824
+ source: localSource,
1825
+ message: '下書きの対象を確認できません。入力と提案は保持しています。対象を確認してから再試行してください。',
1826
+ };
1827
+ state.draft = { ...state.draft, state: 'conflict', id: draftId, draft_id: draftId, input_text: localInput, source: localSource };
1828
+ } else {
1829
+ const latestRevision = latestDraft.revision;
1830
+ state.candidate = {
1831
+ ...state.candidate,
1832
+ state: 'conflict',
1833
+ candidate_readback_pending: latestRevision === null,
1834
+ draft_id: latestDraft.draft_id,
1835
+ version: latestRevision,
1836
+ latest_revision: latestRevision,
1837
+ candidate: localCandidate,
1838
+ input_text: localInput,
1839
+ source: localSource,
1840
+ message: latestRevision === null
1841
+ ? '下書きの最新版は確認しましたが、版を確認できません。入力と提案は保持しています。再試行してください。'
1842
+ : '下書きの最新版を確認しました。入力と提案は保持しています。確認後に下書きを保存してください。',
1843
+ };
1844
+ state.draft = {
1845
+ ...state.draft,
1846
+ state: 'candidate',
1847
+ id: latestDraft.draft_id,
1848
+ draft_id: latestDraft.draft_id,
1849
+ revision: latestRevision,
1850
+ input_text: localInput,
1851
+ source: localSource,
1852
+ };
1853
+ }
1854
+ } catch (error) {
1855
+ state.candidate = {
1856
+ ...state.candidate,
1857
+ state: 'conflict',
1858
+ candidate_readback_pending: true,
1859
+ draft_id: draftId,
1860
+ version: null,
1861
+ latest_revision: null,
1862
+ candidate: localCandidate,
1863
+ input_text: localInput,
1864
+ source: localSource,
1865
+ message: `下書きの最新版を確認できません。入力と提案は保持しています。${apiErrorText(error)}`,
1866
+ error: error.code,
1867
+ ...conflictMetadataFrom(error),
1868
+ };
1869
+ state.draft = { ...state.draft, state: 'conflict', id: draftId, draft_id: draftId, input_text: localInput, source: localSource };
1870
+ }
1528
1871
  render(); return state.candidate;
1529
1872
  }
1530
1873
 
1531
1874
  async function commitCandidate(values) {
1532
1875
  if (state.commit.state === 'saving' || state.commit.pending_save || ['verified', 'saved_unverified'].includes(state.commit.state)) return state.commit;
1876
+ if (state.candidate.state === 'saving' || state.candidate.candidate_readback_pending === true) return state.commit;
1877
+ const previousDraftConflict = state.commit.pending_draft_update;
1533
1878
  const draftId = state.candidate.draft_id ?? state.draft.id;
1534
1879
  if (!draftId) { state.commit = { state: 'unknown', message: '下書きIDを確認できません。' }; render(); return null; }
1535
- state.commit = { state: 'saving', item_id: null, version: null }; render();
1880
+ state.commit = { state: 'saving', item_id: null, version: null, ...(previousDraftConflict ? { pending_draft_update: previousDraftConflict } : {}) }; render();
1536
1881
  try {
1537
1882
  const resolvedDomain = await resolveDecisionDomain?.({ project, session: typeof session === 'function' ? session() : session, candidate: values });
1538
1883
  const decisionDomain = resolvedDomain ?? values.decision_domain ?? null;
@@ -1553,25 +1898,148 @@ export function createKnowledgeOutcomeController(options = {}) {
1553
1898
  state.commit = { state: 'unknown', message: '再利用する正本IDと現在版を確認できません。' };
1554
1899
  render(); return state.commit;
1555
1900
  }
1556
- const revision = state.candidate.version ?? state.draft.revision ?? state.draft.version ?? null;
1557
- const updated = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}`), { method: 'PATCH', body: { ...candidateBody, revision }, key: operationKey('draft-update', draftId, revision, candidateBody) });
1901
+ const revision = previousDraftConflict
1902
+ ? previousDraftConflict.latest_revision
1903
+ : state.candidate.version ?? state.draft.revision ?? state.draft.version ?? null;
1904
+ if (draftId && (revision === null || revision === undefined || optionalText(revision).trim() === '')) {
1905
+ state.commit = {
1906
+ state: 'unknown',
1907
+ item_id: null,
1908
+ version: null,
1909
+ message: '下書きの版を確認できないため、保存を停止しました。最新版を確認してから再試行してください。',
1910
+ };
1911
+ render(); return state.commit;
1912
+ }
1913
+ // Capture every value currently visible in the proposal form before the
1914
+ // request can fail. A conflict rerender must not reconstruct the form
1915
+ // from the original AI proposal and discard edits made by the user.
1916
+ state.candidate = {
1917
+ ...state.candidate,
1918
+ candidate: { ...(state.candidate.candidate ?? {}), ...values },
1919
+ input_text: state.draft.input_text,
1920
+ draft_id: draftId,
1921
+ version: revision,
1922
+ state: 'candidate',
1923
+ proposal_applied: true,
1924
+ };
1925
+ const draftUpdate = {
1926
+ draftId,
1927
+ body: { ...candidateBody, revision },
1928
+ key: operationKey('draft-update', draftId, revision, candidateBody),
1929
+ latest_revision: previousDraftConflict?.latest_revision ?? null,
1930
+ local_input_text: state.draft.input_text,
1931
+ local_source: state.draft.source,
1932
+ };
1933
+ state.commit = { ...state.commit, pending_draft_update: draftUpdate };
1934
+ const updated = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}`), { method: 'PATCH', body: draftUpdate.body, key: draftUpdate.key });
1558
1935
  const savedRevision = updated?.revision ?? revision;
1559
1936
  state.candidate = { ...state.candidate, ...updated, candidate: updated?.candidate ?? values, draft_id: updated?.draft_id ?? updated?.id ?? draftId, version: savedRevision };
1560
- state.draft = { ...state.draft, ...updated, id: updated?.draft_id ?? updated?.id ?? draftId, revision: savedRevision };
1937
+ state.draft = {
1938
+ ...state.draft,
1939
+ ...updated,
1940
+ id: updated?.draft_id ?? updated?.id ?? draftId,
1941
+ revision: savedRevision,
1942
+ input_text: draftUpdate.local_input_text,
1943
+ ...(draftUpdate.local_source !== undefined ? { source: draftUpdate.local_source } : {}),
1944
+ };
1561
1945
  const body = { revision: savedRevision, decision_domain: decisionDomain, ...(selection === 'reuse' ? { canonical_id: canonicalId, expected_version: canonicalVersion } : {}) };
1562
1946
  const action = selection === 'reuse' ? 'reuse' : 'save';
1563
1947
  const key = operationKey(action, draftId, savedRevision, body);
1564
- state.commit = { ...state.commit, pending_save: { draftId, body, key, action, expected_relations: candidateBody.relations } };
1948
+ state.commit = { ...state.commit, pending_save: { draftId, body, key, action, expected_relations: candidateBody.relations }, pending_draft_update: null };
1565
1949
  const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}/${action}`), { method: 'POST', body, key });
1566
1950
  const readback = await verifyMutationReadback(payload, candidateBody.relations, action);
1567
1951
  state.commit = readback;
1568
1952
  if (readback.item) { state.selected = readback.item; state.detail = { state: 'ready', item: readback.item }; state.lifecycle = { state: 'ready', item: readback.item }; }
1569
1953
  onCommitted?.(readback);
1570
- } catch (error) { state.commit = { ...state.commit, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
1954
+ } catch (error) {
1955
+ const pendingDraftUpdate = state.commit.pending_draft_update;
1956
+ const nextPendingDraftUpdate = pendingDraftUpdate && isVersionConflict(error)
1957
+ ? { ...pendingDraftUpdate, latest_revision: null }
1958
+ : pendingDraftUpdate;
1959
+ state.commit = {
1960
+ ...state.commit,
1961
+ ...(nextPendingDraftUpdate ? { pending_draft_update: nextPendingDraftUpdate } : {}),
1962
+ state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable',
1963
+ message: apiErrorText(error),
1964
+ error: error.code,
1965
+ ...conflictMetadataFrom(error),
1966
+ };
1967
+ }
1968
+ render(); return state.commit;
1969
+ }
1970
+
1971
+ async function retryDraftConflictReadback() {
1972
+ const pending = state.commit.pending_draft_update;
1973
+ if (!pending) return readbackCommit();
1974
+ state.commit = { ...state.commit, state: 'saving', message: '下書きの最新版を確認しています。入力と出典は保持されます。' }; render();
1975
+ try {
1976
+ const payload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(pending.draftId)}`));
1977
+ const latestDraft = draftReadbackFrom(payload, pending.draftId);
1978
+ const latestRevision = latestDraft.valid ? latestDraft.revision : null;
1979
+ if (!latestDraft.valid) {
1980
+ state.commit = {
1981
+ ...state.commit,
1982
+ state: 'conflict',
1983
+ latest_revision: null,
1984
+ pending_draft_update: { ...pending, latest_revision: null, latest_draft_metadata: null },
1985
+ message: '下書きの対象を確認できません。入力と出典は保持しています。対象を確認してから再試行してください。',
1986
+ };
1987
+ } else {
1988
+ state.commit = {
1989
+ ...state.commit,
1990
+ state: 'conflict',
1991
+ latest_revision: latestRevision,
1992
+ pending_draft_update: {
1993
+ ...pending,
1994
+ latest_revision: latestRevision,
1995
+ latest_draft_metadata: {
1996
+ draft_id: latestDraft.draft_id,
1997
+ revision: latestRevision,
1998
+ ...(latestDraft.content !== undefined ? { content: latestDraft.content } : {}),
1999
+ ...(latestDraft.source ? { source: latestDraft.source } : {}),
2000
+ },
2001
+ },
2002
+ message: latestRevision === null
2003
+ ? '下書きの対象は確認しましたが、最新版の版を確認できません。入力と出典は保持しています。再試行してください。'
2004
+ : '下書きの最新版を確認しました。入力と出典は保持しています。内容を比較してから再適用してください。',
2005
+ };
2006
+ }
2007
+ } catch (error) {
2008
+ state.commit = {
2009
+ ...state.commit,
2010
+ state: 'conflict',
2011
+ latest_revision: null,
2012
+ pending_draft_update: { ...pending, latest_revision: null },
2013
+ message: `下書きの最新版を確認できません。入力と出典は保持しています。${apiErrorText(error)}`,
2014
+ error: error.code,
2015
+ ...conflictMetadataFrom(error),
2016
+ };
2017
+ }
1571
2018
  render(); return state.commit;
1572
2019
  }
1573
2020
 
2021
+ async function reapplyPendingDraftUpdate() {
2022
+ const pending = state.commit.pending_draft_update;
2023
+ if (!pendingDraftUpdateReadyForReapply(pending)) return state.commit;
2024
+ // commitCandidate marks the commit as saving before its first await. This
2025
+ // also makes a second click on a detached button a no-op.
2026
+ if (state.commit.state === 'saving' || state.commit.pending_save) return state.commit;
2027
+ const candidateValues = { ...(state.candidate.candidate ?? {}) };
2028
+ if (!Object.keys(candidateValues).length) return state.commit;
2029
+ state.draft = {
2030
+ ...state.draft,
2031
+ input_text: pending.local_input_text ?? state.draft.input_text,
2032
+ ...(Object.prototype.hasOwnProperty.call(pending, 'local_source') ? { source: pending.local_source } : {}),
2033
+ };
2034
+ return commitCandidate(candidateValues);
2035
+ }
2036
+
1574
2037
  async function retryPendingSave() {
2038
+ const pendingDraftUpdate = state.commit.pending_draft_update;
2039
+ if (pendingDraftUpdate) {
2040
+ if (pendingDraftUpdate.latest_revision === undefined || pendingDraftUpdate.latest_revision === null) return retryDraftConflictReadback();
2041
+ return state.commit;
2042
+ }
1575
2043
  const pending = state.commit.pending_save;
1576
2044
  if (!pending) return readbackCommit();
1577
2045
  state.commit = { ...state.commit, state: 'saving' }; render();
@@ -1581,7 +2049,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1581
2049
  if (state.commit.item) state.selected = state.commit.item;
1582
2050
  onCommitted?.(state.commit);
1583
2051
  } catch (error) {
1584
- state.commit = { ...state.commit, pending_save: pending, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code };
2052
+ state.commit = { ...state.commit, pending_save: pending, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) };
1585
2053
  }
1586
2054
  render(); return state.commit;
1587
2055
  }
@@ -1651,7 +2119,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1651
2119
  const matches = expected?.id === state.selected.id && expected.version !== null && item?.id === expected.id && item?.version === expected.version && expectedHash !== null && actualHash === expectedHash && sameReturnedFields;
1652
2120
  if (item) { state.selected = item; state.detail = { state: 'ready', item }; }
1653
2121
  state.lifecycle = { state: matches ? 'verified' : 'saved_unverified', action: 'revision', item_id: expected?.id ?? state.selected?.id, version: item?.version, expected_version: expected?.version, expected_hash: expectedHash, item };
1654
- } catch (error) { state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'revision', revisionDraft: supplied, message: apiErrorText(error), error: error.code }; }
2122
+ } catch (error) { state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'revision', revisionDraft: supplied, message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) }; }
1655
2123
  render(); return state.lifecycle;
1656
2124
  }
1657
2125
 
@@ -1666,7 +2134,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1666
2134
  const item = normalizeKnowledgeItem(current?.item ?? current?.record ?? current?.data ?? current);
1667
2135
  const result = { state: item?.id === state.selected.id && item.lifecycle.status === 'retired' ? 'verified' : 'saved_unverified', item_id: state.selected.id, version: item?.version ?? state.selected.version, item };
1668
2136
  state.lifecycle = { ...result, action: 'retire' }; if (result.item) { state.selected = result.item; state.detail = { state: 'ready', item: result.item }; } onRetired?.(result);
1669
- } catch (error) { state.lifecycle = { state: error.code === 'version_conflict' ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
2137
+ } catch (error) { state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) }; }
1670
2138
  render(); return state.lifecycle;
1671
2139
  }
1672
2140
 
@@ -1693,7 +2161,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1693
2161
  if (replacement) { state.selected = replacement; state.detail = { state: 'ready', item: replacement }; }
1694
2162
  state.lifecycle = { state: matches ? 'verified' : 'saved_unverified', action: 'supersession', item_id: replacement?.id, version: replacement?.version, superseded_item_id: superseded?.id, item: replacement };
1695
2163
  } catch (error) {
1696
- state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'supersession', supersessionDraft: supplied, message: apiErrorText(error), error: error.code };
2164
+ state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'supersession', supersessionDraft: supplied, message: apiErrorText(error), error: error.code, ...conflictMetadataFrom(error) };
1697
2165
  }
1698
2166
  render(); return state.lifecycle;
1699
2167
  }
@@ -1742,7 +2210,7 @@ export function createKnowledgeOutcomeController(options = {}) {
1742
2210
  render(); return state.preview;
1743
2211
  }
1744
2212
 
1745
- const controller = { state, render, loadItems, loadAuthorityDomains, loadDestination, saveDestination, selectItem, createDraft, resumeDraft, discardDraft, applyProposal, saveCandidate, commitCandidate, retryPendingSave, readbackCommit, reviseItem, supersedeItem, retireItem, runPreview, paths: { items: () => buildKnowledgeItemsPath(code, state.filters), drafts: () => knowledgePath(code, KNOWLEDGE_ROUTES.drafts), destination: destinationPath } };
2213
+ const controller = { state, render, loadItems, loadAuthorityDomains, loadDestination, saveDestination, selectItem, createDraft, resumeDraft, discardDraft, applyProposal, saveCandidate, commitCandidate, retryPendingSave, reapplyPendingDraftUpdate, readbackCommit, reviseItem, supersedeItem, retireItem, runPreview, paths: { items: () => buildKnowledgeItemsPath(code, state.filters), drafts: () => knowledgePath(code, KNOWLEDGE_ROUTES.drafts), destination: destinationPath } };
1746
2214
  render();
1747
2215
  const initialLocation = options.location ?? globalThis.location;
1748
2216
  if (initialLocation?.href) {