@unson/brainbase-mcp 0.22.4 → 0.22.5
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.
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FoundationHttpCsrfVerifier, type FoundationHttpRouter } from './foundation-http.js';
|
|
2
|
+
import type { ObjectiveDefinition } from './ontology-foundation.js';
|
|
2
3
|
/**
|
|
3
4
|
* Readiness probe for the Graph history contract used by the public adapter.
|
|
4
5
|
*
|
|
@@ -31,8 +32,20 @@ export interface FoundationGraphHttpOptions {
|
|
|
31
32
|
readonly withAccessContext: <T>(identity: FoundationGraphTrustedIdentity, callback: (client: FoundationGraphQueryClient) => Promise<T>) => Promise<T>;
|
|
32
33
|
/** Host-owned CSRF verification port for mutation requests. */
|
|
33
34
|
readonly csrf?: FoundationHttpCsrfVerifier;
|
|
35
|
+
/** Canonical host writer, using this request's authenticated transaction.
|
|
36
|
+
* Must enforce current ACL, tenant/project access and expectedRevision CAS;
|
|
37
|
+
* null means create-only. Never connect a low-level or maintenance writer.
|
|
38
|
+
*/
|
|
39
|
+
readonly writeObjectiveDraft?: (input: FoundationGraphObjectiveWriteInput) => Promise<void>;
|
|
34
40
|
readonly bodyLimitBytes?: number;
|
|
35
41
|
}
|
|
42
|
+
export interface FoundationGraphObjectiveWriteInput {
|
|
43
|
+
readonly definition: ObjectiveDefinition;
|
|
44
|
+
readonly expectedRevision: string | null;
|
|
45
|
+
readonly projectCode: string;
|
|
46
|
+
readonly identity: FoundationGraphTrustedIdentity;
|
|
47
|
+
readonly client: FoundationGraphQueryClient;
|
|
48
|
+
}
|
|
36
49
|
/**
|
|
37
50
|
* Create the reusable Graph-backed public Foundation handler.
|
|
38
51
|
*
|
|
@@ -3,6 +3,8 @@ import { checkObjectiveReadiness } from './company-os-objectives.js';
|
|
|
3
3
|
import { createFoundationPublicProvider, createFoundationPublicRoute } from './foundation-public-provider.js';
|
|
4
4
|
import { createGraphFoundationReaders } from './graph-foundation-reader.js';
|
|
5
5
|
import { FoundationStoreError } from './foundation-store.js';
|
|
6
|
+
import { digestFoundationDefinition, nextFoundationRevision } from './foundation-catalog.js';
|
|
7
|
+
import { validateFoundationGraphWrite } from './foundation-graph-write.js';
|
|
6
8
|
/**
|
|
7
9
|
* Readiness probe for the Graph history contract used by the public adapter.
|
|
8
10
|
*
|
|
@@ -24,6 +26,54 @@ export const FOUNDATION_GRAPH_READY_SQL = `SELECT
|
|
|
24
26
|
export const FOUNDATION_GRAPH_CONTRACT_VERSION = 'foundation-graph-http.v1';
|
|
25
27
|
export const FOUNDATION_OVERVIEW_CONTRACT_VERSION = 'foundation-overview.v1';
|
|
26
28
|
const FOUNDATION_CATALOG_PATH = '/api/foundation/catalog';
|
|
29
|
+
class FoundationGraphResponseError extends Error {
|
|
30
|
+
response;
|
|
31
|
+
constructor(response) {
|
|
32
|
+
super('Foundation mutation was rejected');
|
|
33
|
+
this.response = response;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Domain proposals can never supply the host's authority or provenance. */
|
|
37
|
+
function graphObjectiveBuilder(projectCode) {
|
|
38
|
+
return ({ operation, body, context, current, expectedRevision }) => {
|
|
39
|
+
if (body.adoptionState !== undefined && body.adoptionState !== 'draft') {
|
|
40
|
+
throw new FoundationStoreError('invalid_input', 'Objective writes accept draft definitions only');
|
|
41
|
+
}
|
|
42
|
+
const domain = {};
|
|
43
|
+
for (const key of ['meaning', 'desiredState', 'beneficiaryIds', 'criteria', 'evaluationPeriod', 'accountableId', 'epistemicState']) {
|
|
44
|
+
if (body[key] !== undefined)
|
|
45
|
+
domain[key] = body[key];
|
|
46
|
+
}
|
|
47
|
+
const blank = (value) => value === undefined || value === null || (typeof value === 'string' && !value.trim());
|
|
48
|
+
if (isRecord(domain.evaluationPeriod)
|
|
49
|
+
&& blank(domain.evaluationPeriod.from) && blank(domain.evaluationPeriod.until))
|
|
50
|
+
delete domain.evaluationPeriod;
|
|
51
|
+
if (blank(domain.accountableId))
|
|
52
|
+
delete domain.accountableId;
|
|
53
|
+
if (operation === 'update') {
|
|
54
|
+
if (!current)
|
|
55
|
+
throw new FoundationStoreError('not_found', 'The current Objective is required');
|
|
56
|
+
if (current.revision !== expectedRevision) {
|
|
57
|
+
throw new FoundationStoreError('revision_conflict', 'Objective changed; reload before saving', { currentRevision: current.revision });
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
...domain, id: current.id, type: 'objective', revision: nextFoundationRevision(current.revision),
|
|
61
|
+
adoptionState: 'draft', acl: current.acl, scope: current.scope, storage: current.storage,
|
|
62
|
+
provenance: current.provenance, authorizedUses: current.authorizedUses
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const id = typeof body.id === 'string' ? body.id.trim() : '';
|
|
66
|
+
if (!id)
|
|
67
|
+
throw new FoundationStoreError('invalid_input', 'id is required');
|
|
68
|
+
return {
|
|
69
|
+
...domain, id, type: 'objective', revision: '1', adoptionState: 'draft',
|
|
70
|
+
acl: { ownerId: context.principal, visibility: 'private', readerIds: [], writerIds: [] },
|
|
71
|
+
scope: { subjectIds: [projectCode], validFrom: new Date().toISOString() },
|
|
72
|
+
storage: 'candidate', authorizedUses: ['draft'],
|
|
73
|
+
provenance: [{ sourceId: 'foundation-objective-editor', sourceKind: 'candidate', evidenceIds: [] }]
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
27
77
|
const DEFAULT_BODY_LIMIT_BYTES = 65_536;
|
|
28
78
|
const DIRECT_FOUNDATION_REFERENCE_KINDS = new Set(['objective', 'variable', 'model', 'constraint', 'philosophy']);
|
|
29
79
|
const SNAPSHOT_REFERENCE_KINDS = new Set([
|
|
@@ -298,19 +348,47 @@ export function createFoundationGraphHttpHandler(options) {
|
|
|
298
348
|
const provider = createFoundationPublicProvider(readers);
|
|
299
349
|
const route = createFoundationPublicRoute(provider);
|
|
300
350
|
const catalogRoute = createFoundationCatalogRoute(readers, scopeId, context);
|
|
351
|
+
const writeObjective = async (definition, expectedRevision) => {
|
|
352
|
+
if (!options.writeObjectiveDraft) {
|
|
353
|
+
throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured');
|
|
354
|
+
}
|
|
355
|
+
const current = expectedRevision === null ? undefined
|
|
356
|
+
: await readers.store.readLatest('objective', definition.id, context);
|
|
357
|
+
if (expectedRevision !== null && !current)
|
|
358
|
+
throw new FoundationStoreError('not_found', 'Objective was not found');
|
|
359
|
+
if (current && current.definition.revision !== expectedRevision) {
|
|
360
|
+
throw new FoundationStoreError('revision_conflict', 'Objective changed; reload before saving', { currentRevision: current.definition.revision });
|
|
361
|
+
}
|
|
362
|
+
const checked = validateFoundationGraphWrite({
|
|
363
|
+
context: { principal: identity.principal, projectCode: scopeId },
|
|
364
|
+
row: { id: definition.id, type: 'objective', projectCode: scopeId, revision: definition.revision, payload: { foundation: definition } },
|
|
365
|
+
expectedNextRevision: expectedRevision === null ? '1' : nextFoundationRevision(expectedRevision),
|
|
366
|
+
...(current ? { currentDefinition: current.definition } : {})
|
|
367
|
+
});
|
|
368
|
+
if (!checked.valid || !checked.normalized) {
|
|
369
|
+
const denied = checked.issues.some((issue) => ['WRITER_NOT_AUTHORIZED', 'OWNER_MISMATCH', 'ACL_CHANGE_FORBIDDEN', 'SCOPE_VIOLATION'].includes(issue.code));
|
|
370
|
+
throw new FoundationStoreError(denied ? 'authorization_denied' : 'invalid_input', 'Objective draft validation failed');
|
|
371
|
+
}
|
|
372
|
+
const candidate = checked.normalized.definition;
|
|
373
|
+
await options.writeObjectiveDraft({ definition: candidate, expectedRevision, projectCode: scopeId, identity, client });
|
|
374
|
+
const saved = await readers.store.read({ id: candidate.id, type: 'objective', revision: candidate.revision }, context);
|
|
375
|
+
const digest = digestFoundationDefinition(candidate);
|
|
376
|
+
if (!saved || saved.digest !== digest) {
|
|
377
|
+
throw new FoundationStoreError('readback_mismatch', 'The saved Objective revision could not be verified');
|
|
378
|
+
}
|
|
379
|
+
return { id: candidate.id, type: 'objective', revision: candidate.revision, digest };
|
|
380
|
+
};
|
|
301
381
|
const objectiveRoute = createObjectiveFoundationRoute({
|
|
382
|
+
...(options.writeObjectiveDraft ? { buildDefinition: graphObjectiveBuilder(scopeId) } : {}),
|
|
383
|
+
bodyLimitBytes,
|
|
302
384
|
objectives: {
|
|
303
|
-
|
|
304
|
-
throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
|
|
305
|
-
},
|
|
385
|
+
createObjective: (definition) => writeObjective(definition, null),
|
|
306
386
|
async readObjective(id, objectiveContext, revision) {
|
|
307
387
|
return revision === undefined
|
|
308
388
|
? readers.store.readLatest('objective', id, objectiveContext)
|
|
309
389
|
: readers.store.read({ id, type: 'objective', revision }, objectiveContext);
|
|
310
390
|
},
|
|
311
|
-
|
|
312
|
-
throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
|
|
313
|
-
},
|
|
391
|
+
updateObjective: (_id, expectedRevision, definition) => writeObjective(definition, expectedRevision),
|
|
314
392
|
async checkObjectiveReadiness(id, objectiveContext, revision) {
|
|
315
393
|
return checkObjectiveReadiness(readers.store, id, objectiveContext, revision);
|
|
316
394
|
},
|
|
@@ -323,13 +401,21 @@ export function createFoundationGraphHttpHandler(options) {
|
|
|
323
401
|
routes: [catalogRoute, route, objectiveRoute],
|
|
324
402
|
bodyLimitBytes
|
|
325
403
|
});
|
|
326
|
-
|
|
404
|
+
const result = await handler.handle(request);
|
|
405
|
+
// The common HTTP router renders domain errors. Re-throw the response
|
|
406
|
+
// until the host transaction rolls back, including failed readback.
|
|
407
|
+
if (!['GET', 'HEAD'].includes(request.method.toUpperCase()) && result.status >= 400) {
|
|
408
|
+
throw new FoundationGraphResponseError(result);
|
|
409
|
+
}
|
|
410
|
+
return result;
|
|
327
411
|
});
|
|
328
412
|
return response instanceof Response
|
|
329
413
|
? response
|
|
330
414
|
: jsonResponse(503, { error: { code: 'foundation_provider_unavailable' } });
|
|
331
415
|
}
|
|
332
|
-
catch {
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (error instanceof FoundationGraphResponseError)
|
|
418
|
+
return error.response;
|
|
333
419
|
return jsonResponse(503, { error: { code: 'foundation_provider_unavailable' } });
|
|
334
420
|
}
|
|
335
421
|
}
|
|
@@ -77,6 +77,15 @@ export interface ObjectiveFoundationHttpRouteOptions {
|
|
|
77
77
|
replaceObjectiveConstraintRefs?: (reference: FoundationRef, refs: readonly FoundationRevision[], context: FoundationStoreContext) => MaybePromise<unknown>;
|
|
78
78
|
bodyLimitBytes?: number;
|
|
79
79
|
}
|
|
80
|
+
/** Typed, explicitly sanitized error for trusted host adapters. */
|
|
81
|
+
export declare class FoundationHttpError extends Error {
|
|
82
|
+
readonly status: number;
|
|
83
|
+
readonly code: string;
|
|
84
|
+
readonly currentRevision?: string;
|
|
85
|
+
constructor(status: number, code: string, message: string, options?: {
|
|
86
|
+
currentRevision?: string;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
80
89
|
/**
|
|
81
90
|
* Compose OSS Company OS routes under one local server boundary. The
|
|
82
91
|
* resolver is called only for a matching route and the resulting trusted
|
package/dist/foundation-http.js
CHANGED
|
@@ -65,7 +65,8 @@ const AUTHORITY_FIELDS = new Set([
|
|
|
65
65
|
'permission',
|
|
66
66
|
'permissions',
|
|
67
67
|
]);
|
|
68
|
-
|
|
68
|
+
/** Typed, explicitly sanitized error for trusted host adapters. */
|
|
69
|
+
export class FoundationHttpError extends Error {
|
|
69
70
|
status;
|
|
70
71
|
code;
|
|
71
72
|
currentRevision;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.5",
|
|
4
4
|
"description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -386,7 +386,7 @@
|
|
|
386
386
|
"build:world": "node scripts/build-world-vendor.mjs"
|
|
387
387
|
},
|
|
388
388
|
"dependencies": {
|
|
389
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
389
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
390
390
|
"graphology": "0.26.0",
|
|
391
391
|
"sigma": "3.0.3",
|
|
392
392
|
"zod": "^3.25.56"
|
|
@@ -407,5 +407,5 @@
|
|
|
407
407
|
"engines": {
|
|
408
408
|
"node": ">=20"
|
|
409
409
|
},
|
|
410
|
-
"gitHead": "
|
|
410
|
+
"gitHead": "c361ea6dc167dd1ffe372a6f0d6c1f445d0d5ebf"
|
|
411
411
|
}
|
package/ui/objective-editor.js
CHANGED
|
@@ -220,6 +220,17 @@ function canonicalDateTimeValue(value) {
|
|
|
220
220
|
return Number.isNaN(date.getTime()) ? input : date.toISOString();
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
// Empty date inputs are an unspecified draft period. A partial or invalid
|
|
224
|
+
// period must still reach canonical validation instead of being discarded.
|
|
225
|
+
function definitionForSave(definition) {
|
|
226
|
+
const draft = JSON.parse(JSON.stringify(definition));
|
|
227
|
+
const period = objectValue(draft.evaluationPeriod);
|
|
228
|
+
const blankDate = (value) => value === null || value === undefined || (typeof value === 'string' && !value.trim());
|
|
229
|
+
if (period && Object.keys(period).every((key) => ['from', 'until'].includes(key))
|
|
230
|
+
&& blankDate(period.from) && blankDate(period.until)) delete draft.evaluationPeriod;
|
|
231
|
+
return draft;
|
|
232
|
+
}
|
|
233
|
+
|
|
223
234
|
function normalizeReadiness(value) {
|
|
224
235
|
const object = objectValue(value);
|
|
225
236
|
if (!object || typeof object.ready !== 'boolean') return { state: 'unknown', ready: null, issues: null };
|
|
@@ -698,7 +709,7 @@ function renderCriteria(root, draft, callbacks) {
|
|
|
698
709
|
return fieldset;
|
|
699
710
|
}
|
|
700
711
|
|
|
701
|
-
function collectDefinition(form, baseDraft) {
|
|
712
|
+
function collectDefinition(form, baseDraft, { keepEmptyCriteria = false } = {}) {
|
|
702
713
|
const draft = JSON.parse(JSON.stringify(baseDraft ?? {}));
|
|
703
714
|
const value = (name) => findField(form, name)?.value ?? '';
|
|
704
715
|
draft.id = optionalText(value('id')).trim();
|
|
@@ -719,7 +730,7 @@ function collectDefinition(form, baseDraft) {
|
|
|
719
730
|
const variableRevision = value(`criteria.${index}.variable_revision`).trim();
|
|
720
731
|
const operator = value(`criteria.${index}.operator`) || 'equals';
|
|
721
732
|
const target = parseTarget(value(`criteria.${index}.target`));
|
|
722
|
-
if (!variableIdInput.value.trim() && !variableRevision) continue;
|
|
733
|
+
if (!keepEmptyCriteria && !variableIdInput.value.trim() && !variableRevision) continue;
|
|
723
734
|
const criterion = {
|
|
724
735
|
variableRef: { id: variableIdInput.value.trim(), type: 'variable', revision: variableRevision },
|
|
725
736
|
operator,
|
|
@@ -727,7 +738,7 @@ function collectDefinition(form, baseDraft) {
|
|
|
727
738
|
if (target !== undefined) criterion.target = target;
|
|
728
739
|
draft.criteria.push(criterion);
|
|
729
740
|
}
|
|
730
|
-
return draft;
|
|
741
|
+
return definitionForSave(draft);
|
|
731
742
|
}
|
|
732
743
|
|
|
733
744
|
function renderObjectiveForm(root, state, callbacks) {
|
|
@@ -742,7 +753,9 @@ function renderObjectiveForm(root, state, callbacks) {
|
|
|
742
753
|
makeElement('div', { className: 'objective-editor-panel-heading', text: title }),
|
|
743
754
|
makeElement('p', { className: 'objective-editor-muted', text: '保存すると新しい版になり、保存した版を読み戻して表示します。' }),
|
|
744
755
|
);
|
|
745
|
-
if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, {
|
|
756
|
+
if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, {
|
|
757
|
+
onRetry: ['conflict', 'saved_unverified'].includes(state.save.state) ? null : callbacks.onRetrySave,
|
|
758
|
+
}));
|
|
746
759
|
section.append(buildObjectiveForm(state, callbacks));
|
|
747
760
|
return section;
|
|
748
761
|
}
|
|
@@ -776,18 +789,36 @@ function buildObjectiveForm(state, callbacks, { workspace = false } = {}) {
|
|
|
776
789
|
{ value: 'retired', label: '終了' },
|
|
777
790
|
] }).label);
|
|
778
791
|
form.append(governance);
|
|
779
|
-
|
|
792
|
+
const captureDraft = () => callbacks.onDraftChange?.(collectDefinition(form, draft, { keepEmptyCriteria: true }));
|
|
793
|
+
form.append(renderCriteria(form, draft, {
|
|
794
|
+
...callbacks,
|
|
795
|
+
onAddCriterion: () => { captureDraft(); callbacks.onAddCriterion?.(); },
|
|
796
|
+
onRemoveCriterion: (index) => { captureDraft(); callbacks.onRemoveCriterion?.(index); },
|
|
797
|
+
}));
|
|
780
798
|
form.append(renderReadiness(form, state.readiness));
|
|
781
799
|
form.append(renderConstraintRefs(form, state, callbacks));
|
|
782
800
|
const actions = makeElement('div', { className: 'objective-editor-actions' });
|
|
783
801
|
actions.append(makeElement('span', { className: 'objective-editor-muted', text: callbacks.canEdit ? '保存後に同じID・新版を再取得して確認します。' : '読み取り専用です。書込み権限はホストから提供してください。' }));
|
|
784
|
-
const saving = state.save.state === 'saving';
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
802
|
+
const saving = callbacks.saving || state.save.state === 'saving';
|
|
803
|
+
const unverified = state.save.state === 'saved_unverified';
|
|
804
|
+
if (saving) actions.append(makeElement('span', { className: 'objective-editor-muted', text: '保存中の処理は、画面を閉じても続きます。' }));
|
|
805
|
+
if (unverified) actions.append(makeElement('span', { className: 'objective-editor-muted', text: '一覧を再読込し、保存した目的を選び直して確かめてください。' }));
|
|
806
|
+
if (callbacks.canEdit) actions.append(makeElement('button', { className: workspace ? 'bb-ws-button is-primary' : 'objective-editor-button-primary', text: saving ? '保存中…' : '保存', attrs: { type: 'submit' }, disabled: saving || unverified }));
|
|
807
|
+
const dismiss = workspace
|
|
808
|
+
? makeButton('キャンセル', callbacks.onCancelEdit, 'bb-ws-button')
|
|
809
|
+
: makeButton('一覧へ戻る', callbacks.onBack, 'objective-editor-button-secondary');
|
|
810
|
+
actions.append(dismiss);
|
|
788
811
|
form.append(actions);
|
|
812
|
+
if (saving) {
|
|
813
|
+
for (const control of findAll(form, (node) => ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(node.tagName))) {
|
|
814
|
+
if (control !== dismiss) control.disabled = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
form.addEventListener('input', captureDraft);
|
|
818
|
+
form.addEventListener('change', captureDraft);
|
|
789
819
|
form.addEventListener('submit', (event) => {
|
|
790
820
|
event.preventDefault();
|
|
821
|
+
if (saving || unverified) return;
|
|
791
822
|
callbacks.onSubmit?.(collectDefinition(form, draft));
|
|
792
823
|
});
|
|
793
824
|
return form;
|
|
@@ -1107,7 +1138,7 @@ function renderObjectiveFormRail(doc, state, callbacks) {
|
|
|
1107
1138
|
// A conflict keeps the input; saving again with the same revision would fail again, so no retry there.
|
|
1108
1139
|
parts.push(workspaceStatusNotice(doc, state.save, {
|
|
1109
1140
|
label: '保存',
|
|
1110
|
-
onRetry: state.save.state
|
|
1141
|
+
onRetry: ['conflict', 'saved_unverified'].includes(state.save.state) ? null : callbacks.onRetrySave,
|
|
1111
1142
|
retryLabel: 'もう一度保存',
|
|
1112
1143
|
}));
|
|
1113
1144
|
}
|
|
@@ -1219,20 +1250,35 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1219
1250
|
};
|
|
1220
1251
|
// The objective shown before 新しい目的, to return to when the owner cancels.
|
|
1221
1252
|
let beforeCreate = null;
|
|
1253
|
+
// Requests may finish after Cancel or a newer selection. Only the current
|
|
1254
|
+
// screen may consume their results; the save lock survives those changes.
|
|
1255
|
+
let selectionVersion = 0;
|
|
1256
|
+
let editorVersion = 0;
|
|
1257
|
+
let listRequest = 0;
|
|
1258
|
+
let readinessRequest = 0;
|
|
1259
|
+
let constraintRequest = 0;
|
|
1260
|
+
let renderVersion = 0;
|
|
1261
|
+
let activeSave = null;
|
|
1222
1262
|
|
|
1223
1263
|
function render() {
|
|
1224
1264
|
if (!root) return null;
|
|
1265
|
+
const currentRender = ++renderVersion;
|
|
1266
|
+
const currentEditor = editorVersion;
|
|
1225
1267
|
const callbacks = {
|
|
1226
1268
|
canEdit,
|
|
1269
|
+
saving: activeSave !== null,
|
|
1227
1270
|
constraintsEditable,
|
|
1228
1271
|
storyLinks,
|
|
1229
1272
|
onView: (view) => { state.view = view; render(); },
|
|
1230
1273
|
onReload: loadObjectives,
|
|
1231
1274
|
onCreate: beginCreate,
|
|
1232
1275
|
onSelect: selectObjective,
|
|
1233
|
-
onBack: () => { state.view = 'list'; render(); },
|
|
1234
|
-
onSubmit: saveObjective,
|
|
1235
|
-
|
|
1276
|
+
onBack: () => { cancelEdit(); state.view = 'list'; render(); },
|
|
1277
|
+
onSubmit: (definition) => currentEditor === editorVersion && currentRender === renderVersion ? saveObjective(definition) : null,
|
|
1278
|
+
onDraftChange: (definition) => {
|
|
1279
|
+
if (currentEditor === editorVersion && currentRender === renderVersion && !activeSave) state.editor.draft = definition;
|
|
1280
|
+
},
|
|
1281
|
+
onRetrySave: () => saveObjective(state.editor.draft),
|
|
1236
1282
|
onAddCriterion: () => { state.editor.draft.criteria = [...(state.editor.draft.criteria ?? []), { variableRef: { id: '', type: 'variable', revision: '' }, operator: 'equals' }]; render(); },
|
|
1237
1283
|
onRemoveCriterion: (index) => { state.editor.draft.criteria = (state.editor.draft.criteria ?? []).filter((_, itemIndex) => itemIndex !== index); render(); },
|
|
1238
1284
|
onAddConstraintRef: addConstraintRef,
|
|
@@ -1243,6 +1289,10 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1243
1289
|
onEdit: beginEdit,
|
|
1244
1290
|
onCancelEdit: cancelEdit,
|
|
1245
1291
|
};
|
|
1292
|
+
// Detached forms and buttons must not act on a newer editor session.
|
|
1293
|
+
for (const [name, callback] of Object.entries(callbacks)) {
|
|
1294
|
+
if (typeof callback === 'function') callbacks[name] = (...args) => currentRender === renderVersion ? callback(...args) : undefined;
|
|
1295
|
+
}
|
|
1246
1296
|
return rail ? renderObjectiveWorkspace(root, rail, state, callbacks, page, options) : renderObjectiveEditor(root, state, callbacks);
|
|
1247
1297
|
}
|
|
1248
1298
|
|
|
@@ -1256,6 +1306,8 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1256
1306
|
}
|
|
1257
1307
|
|
|
1258
1308
|
async function loadObjectives() {
|
|
1309
|
+
const request = ++listRequest;
|
|
1310
|
+
const version = editorVersion;
|
|
1259
1311
|
state.objectives = { state: 'loading', records: null, absence_confirmed: false };
|
|
1260
1312
|
state.connection = port ? 'ready' : 'api_unavailable';
|
|
1261
1313
|
render();
|
|
@@ -1267,20 +1319,26 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1267
1319
|
return state.objectives;
|
|
1268
1320
|
}
|
|
1269
1321
|
try {
|
|
1270
|
-
|
|
1322
|
+
const payload = await method(context);
|
|
1323
|
+
if (request !== listRequest) return state.objectives;
|
|
1324
|
+
state.objectives = normalizeObjectiveCollection(payload);
|
|
1271
1325
|
state.connection = state.objectives.state === 'ready' || state.objectives.state === 'empty' ? 'ready' : state.objectives.state;
|
|
1272
1326
|
} catch (error) {
|
|
1327
|
+
if (request !== listRequest) return state.objectives;
|
|
1273
1328
|
state.objectives = { state: normalizeObjectiveError(error).state, records: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
|
|
1274
1329
|
state.connection = state.objectives.state;
|
|
1275
1330
|
}
|
|
1276
1331
|
render();
|
|
1277
1332
|
// As on the organization screens, the workspace opens with the first row selected.
|
|
1278
1333
|
const first = state.objectives.records?.[0];
|
|
1279
|
-
if (rail && first && !state.selectedId && state.railMode !== 'form') await selectObjective(first);
|
|
1334
|
+
if (rail && first && !state.selectedId && state.railMode !== 'form' && version === editorVersion) await selectObjective(first);
|
|
1280
1335
|
return state.objectives;
|
|
1281
1336
|
}
|
|
1282
1337
|
|
|
1283
1338
|
async function loadConstraintRefs(objective) {
|
|
1339
|
+
const version = selectionVersion;
|
|
1340
|
+
const request = ++constraintRequest;
|
|
1341
|
+
const current = () => version === selectionVersion && request === constraintRequest;
|
|
1284
1342
|
state.constraints = { state: 'loading', refs: null, absence_confirmed: false };
|
|
1285
1343
|
render();
|
|
1286
1344
|
if (!objective) {
|
|
@@ -1297,13 +1355,15 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1297
1355
|
}
|
|
1298
1356
|
try {
|
|
1299
1357
|
const payload = await method({ id: objective.id, type: 'objective', revision: objective.revision }, context);
|
|
1358
|
+
if (!current()) return state.constraints;
|
|
1300
1359
|
state.constraints = normalizeReferenceCollection(payload, ['refs', 'references', 'constraints', 'records', 'items', 'data']);
|
|
1301
|
-
if (state.constraints.refs) {
|
|
1360
|
+
if (state.constraints.refs && !state.editor.constraintRefsChanged) {
|
|
1302
1361
|
state.editor.constraintRefs = state.constraints.refs;
|
|
1303
1362
|
state.editor.originalConstraintRefs = JSON.parse(JSON.stringify(state.constraints.refs));
|
|
1304
1363
|
state.editor.constraintRefsChanged = false;
|
|
1305
1364
|
}
|
|
1306
1365
|
} catch (error) {
|
|
1366
|
+
if (!current()) return state.constraints;
|
|
1307
1367
|
state.constraints = { state: normalizeObjectiveError(error).state, refs: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
|
|
1308
1368
|
}
|
|
1309
1369
|
render();
|
|
@@ -1312,6 +1372,10 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1312
1372
|
|
|
1313
1373
|
async function loadReadiness(objective) {
|
|
1314
1374
|
if (!objective) return state.readiness;
|
|
1375
|
+
const version = selectionVersion;
|
|
1376
|
+
const request = ++readinessRequest;
|
|
1377
|
+
const current = () => version === selectionVersion && request === readinessRequest
|
|
1378
|
+
&& state.selected?.id === objective.id && state.selected?.revision === objective.revision;
|
|
1315
1379
|
const method = portMethod(port, 'checkObjectiveReadiness');
|
|
1316
1380
|
if (!method) {
|
|
1317
1381
|
state.readiness = { state: 'unknown', ready: null, issues: null, message: '判断に使える状態を確かめる経路がありません。' };
|
|
@@ -1319,8 +1383,11 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1319
1383
|
return state.readiness;
|
|
1320
1384
|
}
|
|
1321
1385
|
try {
|
|
1322
|
-
|
|
1386
|
+
const payload = await method(objective.id, context, objective.revision);
|
|
1387
|
+
if (!current()) return state.readiness;
|
|
1388
|
+
state.readiness = normalizeReadiness(payload);
|
|
1323
1389
|
} catch (error) {
|
|
1390
|
+
if (!current()) return state.readiness;
|
|
1324
1391
|
state.readiness = { state: normalizeObjectiveError(error).state, ready: null, issues: null, ...normalizeObjectiveError(error) };
|
|
1325
1392
|
}
|
|
1326
1393
|
if (state.selected) state.selected.readiness = state.readiness;
|
|
@@ -1333,10 +1400,13 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1333
1400
|
const id = reference?.id;
|
|
1334
1401
|
const revision = reference?.revision;
|
|
1335
1402
|
if (!id) return null;
|
|
1403
|
+
const version = ++selectionVersion;
|
|
1404
|
+
editorVersion += 1;
|
|
1405
|
+
state.selected = null;
|
|
1336
1406
|
state.selectedId = id;
|
|
1407
|
+
state.save = { state: 'idle' };
|
|
1337
1408
|
if (rail) {
|
|
1338
1409
|
state.railMode = 'detail';
|
|
1339
|
-
state.save = { state: 'idle' };
|
|
1340
1410
|
beforeCreate = null;
|
|
1341
1411
|
}
|
|
1342
1412
|
state.view = 'editor';
|
|
@@ -1353,6 +1423,7 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1353
1423
|
}
|
|
1354
1424
|
try {
|
|
1355
1425
|
const payload = await method(id, context, revision);
|
|
1426
|
+
if (version !== selectionVersion) return null;
|
|
1356
1427
|
const objective = normalizeObjectiveRecord(payload);
|
|
1357
1428
|
if (!objective) {
|
|
1358
1429
|
state.editor = { ...state.editor, state: 'missing', message: '指定した目的を保存先から確認できません。' };
|
|
@@ -1370,6 +1441,7 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1370
1441
|
await Promise.allSettled([loadReadiness(objective), loadConstraintRefs(objective)]);
|
|
1371
1442
|
return objective;
|
|
1372
1443
|
} catch (error) {
|
|
1444
|
+
if (version !== selectionVersion) return null;
|
|
1373
1445
|
const normalized = normalizeObjectiveError(error);
|
|
1374
1446
|
state.editor = { ...state.editor, state: normalized.state, message: normalized.message, error: normalized.error };
|
|
1375
1447
|
state.connection = normalized.state;
|
|
@@ -1379,6 +1451,10 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1379
1451
|
}
|
|
1380
1452
|
|
|
1381
1453
|
function beginCreate() {
|
|
1454
|
+
// A repeated click on New must not throw away an in-progress draft.
|
|
1455
|
+
if (state.editor.mode === 'create' && state.railMode === 'form') return;
|
|
1456
|
+
selectionVersion += 1;
|
|
1457
|
+
editorVersion += 1;
|
|
1382
1458
|
if (rail && state.railMode !== 'form') beforeCreate = state.selected;
|
|
1383
1459
|
state.railMode = 'form';
|
|
1384
1460
|
state.view = 'editor';
|
|
@@ -1399,6 +1475,8 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1399
1475
|
/** Workspace layout: open the edit form for the selected objective in the rail. */
|
|
1400
1476
|
function beginEdit() {
|
|
1401
1477
|
if (!state.selected) return;
|
|
1478
|
+
if (state.railMode === 'form' && state.editor.mode === 'edit') return;
|
|
1479
|
+
editorVersion += 1;
|
|
1402
1480
|
state.editor = {
|
|
1403
1481
|
...state.editor,
|
|
1404
1482
|
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(state.selected), expectedRevision: state.selected.revision,
|
|
@@ -1411,12 +1489,14 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1411
1489
|
render();
|
|
1412
1490
|
}
|
|
1413
1491
|
|
|
1414
|
-
/**
|
|
1492
|
+
/** Discard unsaved input and leave the form; an already sent write cannot be undone. */
|
|
1415
1493
|
function cancelEdit() {
|
|
1494
|
+
editorVersion += 1;
|
|
1416
1495
|
const creating = state.editor.mode === 'create';
|
|
1417
1496
|
state.railMode = 'detail';
|
|
1418
1497
|
state.save = { state: 'idle' };
|
|
1419
1498
|
if (creating) {
|
|
1499
|
+
selectionVersion += 1;
|
|
1420
1500
|
const previous = beforeCreate;
|
|
1421
1501
|
beforeCreate = null;
|
|
1422
1502
|
state.editor = { state: 'idle', mode: 'edit', draft: null, expectedRevision: null, constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false };
|
|
@@ -1438,7 +1518,7 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1438
1518
|
}
|
|
1439
1519
|
|
|
1440
1520
|
function addConstraintRef(reference) {
|
|
1441
|
-
if (!constraintsEditable) return;
|
|
1521
|
+
if (!canEdit || !constraintsEditable || activeSave) return;
|
|
1442
1522
|
const normalized = normalizeReference(reference, 'constraint');
|
|
1443
1523
|
if (!normalized || normalized.type !== 'constraint') return;
|
|
1444
1524
|
const refs = state.editor.constraintRefs ?? [];
|
|
@@ -1449,7 +1529,7 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1449
1529
|
}
|
|
1450
1530
|
|
|
1451
1531
|
function removeConstraintRef(reference) {
|
|
1452
|
-
if (!constraintsEditable) return;
|
|
1532
|
+
if (!canEdit || !constraintsEditable || activeSave) return;
|
|
1453
1533
|
state.editor.constraintRefs = (state.editor.constraintRefs ?? []).filter((item) => !(item.id === reference.id && item.revision === reference.revision));
|
|
1454
1534
|
state.editor.constraintRefsChanged = true;
|
|
1455
1535
|
render();
|
|
@@ -1457,7 +1537,16 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1457
1537
|
|
|
1458
1538
|
async function saveObjective(definition) {
|
|
1459
1539
|
if (!definition) return null;
|
|
1540
|
+
if (!canEdit) return { state: 'permission_denied', message: 'この画面では目的を保存できません。' };
|
|
1541
|
+
if (activeSave) return { state: 'saving', action: activeSave.mode };
|
|
1542
|
+
// Once a mutation succeeded, repeating it is unsafe until the owner has
|
|
1543
|
+
// reloaded and selected the canonical revision again.
|
|
1544
|
+
if (state.save.state === 'saved_unverified') return state.save;
|
|
1545
|
+
definition = definitionForSave(definition);
|
|
1460
1546
|
const mode = state.editor.mode;
|
|
1547
|
+
const expectedRevision = state.editor.expectedRevision;
|
|
1548
|
+
const constraintRefsChanged = state.editor.constraintRefsChanged;
|
|
1549
|
+
const constraintRefs = JSON.parse(JSON.stringify(state.editor.constraintRefs ?? []));
|
|
1461
1550
|
const updateMethod = mode === 'create' ? null : portMethod(port, 'updateObjective');
|
|
1462
1551
|
const createMethod = mode === 'create' ? portMethod(port, 'createObjective') : null;
|
|
1463
1552
|
if ((mode === 'create' && !createMethod) || (mode !== 'create' && !updateMethod)) {
|
|
@@ -1465,80 +1554,93 @@ export function createObjectiveEditorController(options = {}) {
|
|
|
1465
1554
|
render();
|
|
1466
1555
|
return state.save;
|
|
1467
1556
|
}
|
|
1468
|
-
if (
|
|
1557
|
+
if (constraintRefsChanged && !portMethod(port, 'replaceObjectiveConstraintRefs')) {
|
|
1469
1558
|
state.save = { state: 'api_unavailable', message: '制約の参照を保存する経路が無いため、目的の本文も保存していません。' };
|
|
1470
1559
|
render();
|
|
1471
1560
|
return state.save;
|
|
1472
1561
|
}
|
|
1562
|
+
const version = editorVersion;
|
|
1563
|
+
const current = () => version === editorVersion;
|
|
1564
|
+
const publish = (result) => {
|
|
1565
|
+
if (current()) { state.save = result; render(); }
|
|
1566
|
+
return result;
|
|
1567
|
+
};
|
|
1568
|
+
const saving = { mode };
|
|
1569
|
+
activeSave = saving;
|
|
1473
1570
|
state.editor.draft = definition;
|
|
1474
1571
|
state.save = { state: 'saving', action: mode };
|
|
1475
1572
|
render();
|
|
1573
|
+
let mutation;
|
|
1574
|
+
let mutationCompleted = false;
|
|
1476
1575
|
try {
|
|
1477
|
-
|
|
1576
|
+
mutation = mode === 'create'
|
|
1478
1577
|
? await createMethod(definition, context)
|
|
1479
|
-
: await updateMethod(definition.id,
|
|
1578
|
+
: await updateMethod(definition.id, expectedRevision, definition, context);
|
|
1579
|
+
mutationCompleted = true;
|
|
1580
|
+
if (!current()) return { state: 'saved_unverified', action: mode, mutation };
|
|
1480
1581
|
const savedRef = extractMutationRef(mutation);
|
|
1481
1582
|
if (!savedRef || savedRef.id !== definition.id || savedRef.type !== 'objective' || !savedRef.revision) {
|
|
1482
|
-
|
|
1483
|
-
render();
|
|
1484
|
-
return state.save;
|
|
1583
|
+
return publish({ state: 'saved_unverified', message: '保存の応答に、同じ目的の新しい版が含まれていません。', mutation });
|
|
1485
1584
|
}
|
|
1486
|
-
const expectedRevision = state.editor.expectedRevision;
|
|
1487
1585
|
if (mode !== 'create' && (!expectedRevision || savedRef.revision === expectedRevision)) {
|
|
1488
|
-
|
|
1586
|
+
return publish({
|
|
1489
1587
|
state: 'saved_unverified',
|
|
1490
1588
|
message: '更新応答にexpectedRevisionとは異なる新版が含まれていません。',
|
|
1491
1589
|
mutation,
|
|
1492
1590
|
expectedRevision,
|
|
1493
1591
|
reference: savedRef,
|
|
1494
|
-
};
|
|
1495
|
-
render();
|
|
1496
|
-
return state.save;
|
|
1592
|
+
});
|
|
1497
1593
|
}
|
|
1498
1594
|
const readMethod = portMethod(port, 'readObjective');
|
|
1499
1595
|
const readback = readMethod ? normalizeObjectiveRecord(await readMethod(savedRef.id, context, savedRef.revision)) : null;
|
|
1596
|
+
if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
|
|
1500
1597
|
const returnedDefinition = readback ? { id: readback.id, type: readback.type, revision: readback.revision, digest: readback.digest } : null;
|
|
1501
1598
|
const referenceMatches = definitionsEqual(savedRef, returnedDefinition);
|
|
1502
1599
|
if (!referenceMatches || !readback || (mode !== 'create' && (!expectedRevision || readback.revision === expectedRevision))) {
|
|
1503
|
-
|
|
1504
|
-
render();
|
|
1505
|
-
return state.save;
|
|
1600
|
+
return publish({ state: 'saved_unverified', message: '保存した新しい版を読み戻して確かめられません。一覧を再読込して確かめてください。', mutation, readback });
|
|
1506
1601
|
}
|
|
1507
|
-
if (
|
|
1602
|
+
if (constraintRefsChanged) {
|
|
1508
1603
|
const replaceRefs = portMethod(port, 'replaceObjectiveConstraintRefs');
|
|
1509
|
-
await replaceRefs(savedRef,
|
|
1604
|
+
await replaceRefs(savedRef, constraintRefs, context);
|
|
1605
|
+
if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
|
|
1510
1606
|
const readConstraintRefs = portMethod(port, 'listObjectiveConstraintRefs') ?? portMethod(port, 'listConstraintReferences');
|
|
1511
1607
|
const constraintPayload = readConstraintRefs
|
|
1512
1608
|
? await readConstraintRefs(savedRef, context)
|
|
1513
1609
|
: null;
|
|
1610
|
+
if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
|
|
1514
1611
|
const verifiedRefs = normalizeReferenceCollection(constraintPayload).refs;
|
|
1515
|
-
if (!verifiedRefs || !referencesEqual(verifiedRefs,
|
|
1516
|
-
|
|
1517
|
-
render();
|
|
1518
|
-
return state.save;
|
|
1612
|
+
if (!verifiedRefs || !referencesEqual(verifiedRefs, constraintRefs)) {
|
|
1613
|
+
return publish({ state: 'saved_unverified', message: '目的は保存されましたが、制約の参照を読み戻した内容が一致しません。', mutation, readback, constraintRefs: verifiedRefs });
|
|
1519
1614
|
}
|
|
1520
1615
|
}
|
|
1616
|
+
// Reads of the previous revision must not replace the saved revision's
|
|
1617
|
+
// readiness or constraints when they finish out of order.
|
|
1618
|
+
selectionVersion += 1;
|
|
1521
1619
|
state.selected = readback;
|
|
1522
1620
|
state.selectedId = readback.id;
|
|
1523
1621
|
state.railMode = 'detail';
|
|
1524
1622
|
beforeCreate = null;
|
|
1525
1623
|
state.editor = {
|
|
1526
1624
|
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(readback), expectedRevision: readback.revision,
|
|
1527
|
-
constraintRefs
|
|
1625
|
+
constraintRefs, originalConstraintRefs: constraintRefs, constraintRefsChanged: false,
|
|
1528
1626
|
};
|
|
1529
|
-
|
|
1627
|
+
const result = { state: 'verified', action: mode, reference: savedRef, readback };
|
|
1628
|
+
state.save = result;
|
|
1530
1629
|
state.connection = 'ready';
|
|
1531
1630
|
// Readiness belongs to a revision; re-read it for the one just saved.
|
|
1532
1631
|
state.readiness = { state: 'unknown', ready: null, issues: null };
|
|
1533
1632
|
render();
|
|
1534
1633
|
await loadReadiness(readback);
|
|
1535
|
-
await loadObjectives();
|
|
1536
|
-
return
|
|
1634
|
+
if (current()) await loadObjectives();
|
|
1635
|
+
return result;
|
|
1537
1636
|
} catch (error) {
|
|
1538
1637
|
const normalized = normalizeObjectiveError(error);
|
|
1539
|
-
|
|
1638
|
+
return publish(mutationCompleted
|
|
1639
|
+
? { state: 'saved_unverified', message: '目的は保存されましたが、保存結果を確かめられません。一覧を再読込して確かめてください。', mutation, error: normalized.error, draft: definition }
|
|
1640
|
+
: { state: normalized.state, message: normalized.message, currentRevision: normalized.currentRevision, error: normalized.error, draft: definition });
|
|
1641
|
+
} finally {
|
|
1642
|
+
if (activeSave === saving) activeSave = null;
|
|
1540
1643
|
render();
|
|
1541
|
-
return state.save;
|
|
1542
1644
|
}
|
|
1543
1645
|
}
|
|
1544
1646
|
|