path-write.mjs
688 lines 22.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Learning-path write facade (KN-WORK-PATH-LIST-b).
3 *
4 * Review-before-write propose + approve-time apply. Gated by PATH_WRITES_ENABLED
5 * (env tri-state; default off). No policy file. Do not edit task-write.mjs.
6 *
7 * @see docs/KN-WORK-PATH-LIST-FREEZE.md
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12
13 import { resolveFlowWriteAuthority } from '../flow/flow-scope.mjs';
14 import { resolveHandlerVisibleScopes } from '../flow/flow-handlers.mjs';
15 import { loadFlowStore, saveFlowStore } from '../flow/flow-store.mjs';
16 import {
17 PATH_ID_RE,
18 WORKSPACE_ID_RE,
19 PATH_EXTERNAL_REF_RE,
20 PATH_SCOPES,
21 PATH_UPDATE_STATUSES,
22 validateLearningPathRecord,
23 validateSteps,
24 validateNotePath,
25 hasControlChars,
26 mintUniquePathId,
27 getLearningPath,
28 loadLearningPaths,
29 ensureLearningPathBucket,
30 MAX_TITLE,
31 MAX_SUMMARY,
32 MAX_GOAL,
33 MAX_ACTIVE_DECISIONS,
34 } from './path-store.mjs';
35
36 export const PATH_PROPOSAL_SOURCE = 'learning_path';
37 export const PATH_REVIEW_QUEUE = 'learning-path';
38 export const PATH_PROPOSAL_SCHEMA = 'knowtation.learning_path_proposal/v0';
39 export const PATH_PROPOSAL_KINDS = /** @type {const} */ (['path_create', 'path_update', 'path_archive']);
40 export const DEFAULT_WORKSPACE_ID = 'ws-personal';
41
42 /** @typedef {typeof PATH_PROPOSAL_KINDS[number]} PathProposalKind */
43 /** @typedef {'personal'|'project'|'org'} PathScope */
44
45 /**
46 * @param {unknown} v
47 * @returns {boolean|null}
48 */
49 function envTriState(v) {
50 if (v === '1' || v === 'true') return true;
51 if (v === '0' || v === 'false') return false;
52 return null;
53 }
54
55 /**
56 * PATH_WRITES_ENABLED: `1`/`true` on; unset/`false`/`0` off. No policy file.
57 *
58 * @returns {boolean}
59 */
60 export function getPathWritesEnabled() {
61 return envTriState(process.env.PATH_WRITES_ENABLED) === true;
62 }
63
64 /**
65 * @param {number} status
66 * @param {string} code
67 * @param {string} [error]
68 */
69 function refuse(status, code, error) {
70 return { ok: false, status, error: error ?? code, code };
71 }
72
73 /**
74 * @param {Set<PathScope>} visibleScopes
75 * @param {PathScope} targetScope
76 */
77 export function resolvePathWriteAuthority(visibleScopes, targetScope) {
78 const authority = resolveFlowWriteAuthority(visibleScopes, targetScope);
79 if (!authority.ok) {
80 return {
81 ok: false,
82 status: authority.status,
83 error:
84 authority.code === 'FLOW_SCOPE_DENIED'
85 ? 'Path write scope not authorized'
86 : authority.error,
87 code: authority.code === 'FLOW_SCOPE_DENIED' ? 'PATH_SCOPE_DENIED' : authority.code,
88 };
89 }
90 return { ok: true };
91 }
92
93 /**
94 * @param {string} proposalId
95 */
96 export function pathProposalMirrorPath(proposalId) {
97 return `meta/learning-paths/proposals/${proposalId}.json`;
98 }
99
100 /**
101 * @param {object} input
102 * @param {object} proposalInput
103 */
104 async function createProposalRecord(input, proposalInput) {
105 const withSession = {
106 ...proposalInput,
107 ...(typeof input.sessionBound === 'boolean' ? { session_bound: input.sessionBound } : {}),
108 };
109 return await Promise.resolve(input.createProposal(input.dataDir, withSession));
110 }
111
112 /**
113 * @param {string} dataDir
114 * @param {string} proposalId
115 */
116 function updateProposalPath(dataDir, proposalId) {
117 const fp = path.join(dataDir, 'hub_proposals.json');
118 if (!fs.existsSync(fp)) return;
119 const all = JSON.parse(fs.readFileSync(fp, 'utf8'));
120 const idx = all.findIndex((p) => p.proposal_id === proposalId);
121 if (idx >= 0) {
122 all[idx].path = pathProposalMirrorPath(proposalId);
123 fs.writeFileSync(fp, JSON.stringify(all, null, 2), 'utf8');
124 }
125 }
126
127 /**
128 * @param {unknown} raw
129 */
130 function trimTextField(raw, max, required) {
131 if (raw == null) {
132 return required
133 ? { ok: false, code: 'BAD_REQUEST', reason: 'required text field missing' }
134 : { ok: true, value: '' };
135 }
136 if (typeof raw !== 'string') {
137 return { ok: false, code: 'BAD_REQUEST', reason: 'text field must be a string' };
138 }
139 const value = raw.trim();
140 if (required && !value) {
141 return { ok: false, code: 'BAD_REQUEST', reason: 'text field must be non-empty' };
142 }
143 if (value.length > max) {
144 return { ok: false, code: 'BAD_REQUEST', reason: 'text field exceeds max length' };
145 }
146 if (hasControlChars(value)) {
147 return { ok: false, code: 'PATH_TEXT_INVALID', reason: 'text contains control characters' };
148 }
149 return { ok: true, value };
150 }
151
152 /**
153 * @param {object} input
154 */
155 function commonProposeGate(input) {
156 if (!getPathWritesEnabled()) {
157 return refuse(403, 'PATH_WRITES_DISABLED', 'Path writes are disabled');
158 }
159 if (typeof input.createProposal !== 'function') {
160 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
161 }
162 const resolved = resolveHandlerVisibleScopes(input);
163 if (resolved.ambiguous) {
164 return refuse(400, 'PATH_SCOPE_AMBIGUOUS', 'Ambiguous path scope');
165 }
166 return { ok: true, visibleScopes: resolved.visibleScopes };
167 }
168
169 /**
170 * Optional Scooling path external_ref. Malformed → PATH_EXTERNAL_REF_INVALID. Absence allowed.
171 *
172 * @param {object} body
173 */
174 function resolvePathExternalRef(body) {
175 if (!body || typeof body !== 'object') return { ok: true, externalRef: undefined };
176 const raw = body.external_ref;
177 if (raw == null) return { ok: true, externalRef: undefined };
178 const s = String(raw).trim();
179 if (!s) return { ok: true, externalRef: undefined };
180 if (!PATH_EXTERNAL_REF_RE.test(s)) {
181 return refuse(400, 'PATH_EXTERNAL_REF_INVALID', 'external_ref does not match scooling.path:…');
182 }
183 return { ok: true, externalRef: s };
184 }
185
186 /**
187 * Propose path_create | path_update | path_archive. Missing kind defaults to path_create.
188 *
189 * @param {object} input
190 */
191 export async function handlePathProposeRequest(input) {
192 const gate = commonProposeGate(input);
193 if (!gate.ok) return gate;
194
195 const body = input.body && typeof input.body === 'object' ? input.body : {};
196 const proposalKindRaw =
197 typeof input.proposalKind === 'string'
198 ? input.proposalKind.trim()
199 : typeof body.proposal_kind === 'string'
200 ? body.proposal_kind.trim()
201 : '';
202 const proposalKind = proposalKindRaw || 'path_create';
203 if (!PATH_PROPOSAL_KINDS.includes(/** @type {PathProposalKind} */ (proposalKind))) {
204 return refuse(400, 'BAD_REQUEST', 'proposal_kind must be path_create|path_update|path_archive');
205 }
206
207 if (proposalKind === 'path_create') {
208 return await handlePathCreatePropose(input, gate.visibleScopes, body);
209 }
210 if (proposalKind === 'path_update') {
211 return await handlePathUpdatePropose(input, gate.visibleScopes, body);
212 }
213 return await handlePathArchivePropose(input, gate.visibleScopes, body);
214 }
215
216 /**
217 * @param {object} input
218 * @param {Set<PathScope>} visibleScopes
219 * @param {Record<string, unknown>} body
220 */
221 async function handlePathCreatePropose(input, visibleScopes, body) {
222 if (body.path_id != null && String(body.path_id).trim() !== '') {
223 return refuse(400, 'PATH_ID_NOT_ALLOWED', 'Client cannot supply path_id on path_create');
224 }
225
226 const ext = resolvePathExternalRef(body);
227 if (!ext.ok) return ext;
228
229 const title = trimTextField(body.title, MAX_TITLE, true);
230 if (!title.ok) return refuse(400, title.code, title.reason);
231 const summary = trimTextField(body.summary, MAX_SUMMARY, true);
232 if (!summary.ok) return refuse(400, summary.code, summary.reason);
233 const goal = trimTextField(body.goal, MAX_GOAL, true);
234 if (!goal.ok) return refuse(400, goal.code, goal.reason);
235 const activeDecisions = trimTextField(body.active_decisions, MAX_ACTIVE_DECISIONS, false);
236 if (!activeDecisions.ok) return refuse(400, activeDecisions.code, activeDecisions.reason);
237
238 const stepsResult = validateSteps(body.steps);
239 if (!stepsResult.ok) return refuse(400, stepsResult.code, stepsResult.reason);
240
241 let currentStepIndex = 0;
242 if (body.current_step_index != null) {
243 if (
244 typeof body.current_step_index !== 'number' ||
245 !Number.isInteger(body.current_step_index) ||
246 body.current_step_index < 0 ||
247 body.current_step_index >= stepsResult.steps.length
248 ) {
249 return refuse(400, 'PATH_STEP_INDEX_INVALID', 'current_step_index must be >= 0 and < steps.length');
250 }
251 currentStepIndex = body.current_step_index;
252 }
253
254 let scope = 'personal';
255 if (body.scope != null && body.scope !== '') {
256 if (!PATH_SCOPES.includes(/** @type {PathScope} */ (body.scope))) {
257 return refuse(400, 'BAD_REQUEST', 'scope must be personal|project|org');
258 }
259 scope = /** @type {PathScope} */ (body.scope);
260 }
261 const authority = resolvePathWriteAuthority(visibleScopes, scope);
262 if (!authority.ok) return authority;
263
264 let workspaceId = DEFAULT_WORKSPACE_ID;
265 if (body.workspace_id != null && String(body.workspace_id).trim() !== '') {
266 const ws = String(body.workspace_id).trim();
267 if (!WORKSPACE_ID_RE.test(ws)) {
268 return refuse(400, 'BAD_REQUEST', 'invalid workspace_id');
269 }
270 workspaceId = ws;
271 }
272
273 const noteResult = validateNotePath(body.note_path);
274 if (!noteResult.ok) return refuse(400, noteResult.code, noteResult.reason);
275
276 const existingIds = loadLearningPaths(input.dataDir, input.vaultId).map((p) => p.path_id);
277 const pathId = mintUniquePathId(existingIds);
278 const now = new Date().toISOString();
279 const draft = {
280 schema: 'knowtation.learning_path/v0',
281 path_id: pathId,
282 scope,
283 status: 'active',
284 title: title.value,
285 summary: summary.value,
286 goal: goal.value,
287 steps: stepsResult.steps,
288 current_step_index: currentStepIndex,
289 step_count: stepsResult.steps.length,
290 next_step_title: stepsResult.steps[currentStepIndex].title,
291 active_decisions: activeDecisions.value,
292 workspace_id: workspaceId,
293 note_path: noteResult.note_path,
294 created: now,
295 updated: now,
296 ...(ext.externalRef ? { external_ref: ext.externalRef } : {}),
297 };
298
299 const validated = validateLearningPathRecord(draft);
300 if (!validated.ok) return refuse(400, validated.code, validated.reason);
301
302 const proposalBody = JSON.stringify(
303 { proposal_kind: 'path_create', path: validated.path },
304 null,
305 2,
306 );
307 const intent =
308 typeof input.intent === 'string' && input.intent.trim()
309 ? input.intent.trim()
310 : typeof body.intent === 'string' && body.intent.trim()
311 ? body.intent.trim()
312 : 'learning path create';
313
314 const proposal = await createProposalRecord(input, {
315 path: pathProposalMirrorPath('new'),
316 body: proposalBody,
317 frontmatter: {
318 type: 'learning_path_proposal',
319 path_id: pathId,
320 proposal_kind: 'path_create',
321 },
322 intent,
323 source: PATH_PROPOSAL_SOURCE,
324 vault_id: input.vaultId,
325 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
326 review_queue: PATH_REVIEW_QUEUE,
327 ...(ext.externalRef ? { external_ref: ext.externalRef } : {}),
328 });
329
330 updateProposalPath(input.dataDir, proposal.proposal_id);
331
332 return {
333 ok: true,
334 payload: {
335 schema: PATH_PROPOSAL_SCHEMA,
336 proposal_id: proposal.proposal_id,
337 proposal_kind: 'path_create',
338 path_id: pathId,
339 scope,
340 auto_approvable: false,
341 status: 'proposed',
342 review_queue: PATH_REVIEW_QUEUE,
343 },
344 };
345 }
346
347 /**
348 * @param {object} input
349 * @param {Set<PathScope>} visibleScopes
350 * @param {Record<string, unknown>} body
351 */
352 async function handlePathUpdatePropose(input, visibleScopes, body) {
353 const pathId = typeof body.path_id === 'string' ? body.path_id.trim() : '';
354 if (!pathId || !PATH_ID_RE.test(pathId)) {
355 return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
356 }
357
358 if (body.scope != null || body.workspace_id != null) {
359 return refuse(400, 'PATH_SCOPE_IMMUTABLE', 'scope and workspace_id cannot change after create');
360 }
361
362 const existing = getLearningPath(input.dataDir, input.vaultId, pathId, { visibleScopes });
363 if (!existing) {
364 return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
365 }
366
367 const authority = resolvePathWriteAuthority(visibleScopes, existing.scope);
368 if (!authority.ok) return authority;
369
370 if (body.status === 'archived') {
371 return refuse(400, 'BAD_REQUEST', 'status=archived requires path_archive');
372 }
373
374 /** @type {Record<string, unknown>} */
375 const patch = {};
376 if (body.title != null) {
377 const title = trimTextField(body.title, MAX_TITLE, true);
378 if (!title.ok) return refuse(400, title.code, title.reason);
379 patch.title = title.value;
380 }
381 if (body.summary != null) {
382 const summary = trimTextField(body.summary, MAX_SUMMARY, true);
383 if (!summary.ok) return refuse(400, summary.code, summary.reason);
384 patch.summary = summary.value;
385 }
386 if (body.goal != null) {
387 const goal = trimTextField(body.goal, MAX_GOAL, true);
388 if (!goal.ok) return refuse(400, goal.code, goal.reason);
389 patch.goal = goal.value;
390 }
391 if (body.active_decisions != null) {
392 const ad = trimTextField(body.active_decisions, MAX_ACTIVE_DECISIONS, false);
393 if (!ad.ok) return refuse(400, ad.code, ad.reason);
394 patch.active_decisions = ad.value;
395 }
396 if (body.status != null) {
397 if (!PATH_UPDATE_STATUSES.includes(/** @type {'active'|'paused'} */ (body.status))) {
398 return refuse(400, 'BAD_REQUEST', 'status on update must be active|paused');
399 }
400 patch.status = body.status;
401 }
402 if (body.note_path !== undefined) {
403 const noteResult = validateNotePath(body.note_path);
404 if (!noteResult.ok) return refuse(400, noteResult.code, noteResult.reason);
405 patch.note_path = noteResult.note_path;
406 }
407
408 let nextSteps = existing.steps;
409 if (body.steps != null) {
410 const stepsResult = validateSteps(body.steps);
411 if (!stepsResult.ok) return refuse(400, stepsResult.code, stepsResult.reason);
412 nextSteps = stepsResult.steps;
413 patch.steps = nextSteps;
414 }
415
416 if (body.current_step_index != null) {
417 if (
418 typeof body.current_step_index !== 'number' ||
419 !Number.isInteger(body.current_step_index) ||
420 body.current_step_index < 0 ||
421 body.current_step_index >= nextSteps.length
422 ) {
423 return refuse(400, 'PATH_STEP_INDEX_INVALID', 'current_step_index must be >= 0 and < steps.length');
424 }
425 patch.current_step_index = body.current_step_index;
426 } else if (body.steps != null) {
427 if (existing.current_step_index >= nextSteps.length) {
428 return refuse(400, 'PATH_STEP_INDEX_INVALID', 'existing current_step_index no longer fits patched steps');
429 }
430 }
431
432 const intent =
433 typeof input.intent === 'string' && input.intent.trim()
434 ? input.intent.trim()
435 : typeof body.intent === 'string' && body.intent.trim()
436 ? body.intent.trim()
437 : 'learning path update';
438
439 const proposalBody = JSON.stringify(
440 { proposal_kind: 'path_update', path_id: pathId, scope: existing.scope, patch },
441 null,
442 2,
443 );
444 const proposal = await createProposalRecord(input, {
445 path: pathProposalMirrorPath('new'),
446 body: proposalBody,
447 frontmatter: {
448 type: 'learning_path_proposal',
449 path_id: pathId,
450 proposal_kind: 'path_update',
451 },
452 intent,
453 source: PATH_PROPOSAL_SOURCE,
454 vault_id: input.vaultId,
455 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
456 review_queue: PATH_REVIEW_QUEUE,
457 });
458 updateProposalPath(input.dataDir, proposal.proposal_id);
459 return {
460 ok: true,
461 payload: {
462 schema: PATH_PROPOSAL_SCHEMA,
463 proposal_id: proposal.proposal_id,
464 proposal_kind: 'path_update',
465 path_id: pathId,
466 scope: existing.scope,
467 auto_approvable: false,
468 status: 'proposed',
469 review_queue: PATH_REVIEW_QUEUE,
470 },
471 };
472 }
473
474 /**
475 * @param {object} input
476 * @param {Set<PathScope>} visibleScopes
477 * @param {Record<string, unknown>} body
478 */
479 async function handlePathArchivePropose(input, visibleScopes, body) {
480 const pathId = typeof body.path_id === 'string' ? body.path_id.trim() : '';
481 if (!pathId || !PATH_ID_RE.test(pathId)) {
482 return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
483 }
484 const existing = getLearningPath(input.dataDir, input.vaultId, pathId, { visibleScopes });
485 if (!existing) {
486 return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
487 }
488 const authority = resolvePathWriteAuthority(visibleScopes, existing.scope);
489 if (!authority.ok) return authority;
490
491 const intent =
492 typeof input.intent === 'string' && input.intent.trim()
493 ? input.intent.trim()
494 : typeof body.intent === 'string' && body.intent.trim()
495 ? body.intent.trim()
496 : 'learning path archive';
497
498 const proposalBody = JSON.stringify(
499 { proposal_kind: 'path_archive', path_id: pathId, scope: existing.scope },
500 null,
501 2,
502 );
503 const proposal = await createProposalRecord(input, {
504 path: pathProposalMirrorPath('new'),
505 body: proposalBody,
506 frontmatter: {
507 type: 'learning_path_proposal',
508 path_id: pathId,
509 proposal_kind: 'path_archive',
510 },
511 intent,
512 source: PATH_PROPOSAL_SOURCE,
513 vault_id: input.vaultId,
514 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
515 review_queue: PATH_REVIEW_QUEUE,
516 });
517 updateProposalPath(input.dataDir, proposal.proposal_id);
518 return {
519 ok: true,
520 payload: {
521 schema: PATH_PROPOSAL_SCHEMA,
522 proposal_id: proposal.proposal_id,
523 proposal_kind: 'path_archive',
524 path_id: pathId,
525 scope: existing.scope,
526 auto_approvable: false,
527 status: 'proposed',
528 review_queue: PATH_REVIEW_QUEUE,
529 },
530 };
531 }
532
533 /**
534 * Approve-time re-check. No store write. Fail-closed.
535 *
536 * @param {string} dataDir
537 * @param {object} proposal
538 */
539 export function precheckApprovedPathProposal(dataDir, proposal) {
540 if (!getPathWritesEnabled()) {
541 return refuse(403, 'PATH_WRITES_DISABLED', 'Path writes are disabled');
542 }
543 let parsed;
544 try {
545 parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
546 } catch {
547 return refuse(400, 'BAD_REQUEST', 'path proposal body is not valid JSON');
548 }
549 if (!parsed || typeof parsed !== 'object') {
550 return refuse(400, 'BAD_REQUEST', 'path proposal body is not an object');
551 }
552
553 const fm = proposal.frontmatter && typeof proposal.frontmatter === 'object' ? proposal.frontmatter : {};
554 const proposalKind =
555 (typeof parsed.proposal_kind === 'string' && parsed.proposal_kind.trim()) ||
556 (typeof fm.proposal_kind === 'string' && fm.proposal_kind.trim()) ||
557 '';
558 if (!PATH_PROPOSAL_KINDS.includes(/** @type {PathProposalKind} */ (proposalKind))) {
559 return refuse(400, 'BAD_REQUEST', 'unknown path proposal_kind');
560 }
561
562 const vaultId =
563 typeof proposal.vault_id === 'string' && proposal.vault_id.trim() ? proposal.vault_id.trim() : 'default';
564 const visibleScopes = new Set(['personal', 'project', 'org']);
565
566 if (proposalKind === 'path_create') {
567 const validated = validateLearningPathRecord(parsed.path);
568 if (!validated.ok) return refuse(400, validated.code, validated.reason);
569 return { ok: true, vaultId, proposalKind, parsed, path: validated.path };
570 }
571
572 if (proposalKind === 'path_update') {
573 const pathId = typeof parsed.path_id === 'string' ? parsed.path_id.trim() : '';
574 const existing = getLearningPath(dataDir, vaultId, pathId, { visibleScopes });
575 if (!existing) return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
576 return { ok: true, vaultId, proposalKind, parsed, existing };
577 }
578
579 if (proposalKind === 'path_archive') {
580 const pathId = typeof parsed.path_id === 'string' ? parsed.path_id.trim() : '';
581 const existing = getLearningPath(dataDir, vaultId, pathId, { visibleScopes });
582 if (!existing) return refuse(404, 'PATH_NOT_FOUND', 'PATH_NOT_FOUND');
583 return { ok: true, vaultId, proposalKind, parsed, existing };
584 }
585
586 return refuse(400, 'BAD_REQUEST', 'unknown path proposal_kind');
587 }
588
589 /**
590 * Apply a pre-checked path proposal into hub_flow_store.json learning_paths[].
591 *
592 * @param {string} dataDir
593 * @param {object} applyCtx
594 */
595 export function reconcileApprovedPathProposal(dataDir, applyCtx) {
596 const store = loadFlowStore(dataDir);
597 const vaultId = applyCtx.vaultId;
598 if (!store.vaults[vaultId]) {
599 store.vaults[vaultId] = {
600 flows: [],
601 steps: [],
602 runs: [],
603 candidates: [],
604 projections: [],
605 tasks: [],
606 task_loops: [],
607 orchestrator_graphs: [],
608 learning_paths: [],
609 };
610 }
611 const vault = store.vaults[vaultId];
612 ensureLearningPathBucket(vault);
613 const now = new Date().toISOString();
614 const kind = applyCtx.proposalKind;
615
616 if (kind === 'path_create') {
617 const record = { ...applyCtx.path, updated: now };
618 const idx = vault.learning_paths.findIndex((p) => p.path_id === record.path_id);
619 if (idx >= 0) {
620 record.created = vault.learning_paths[idx].created;
621 vault.learning_paths[idx] = record;
622 } else {
623 vault.learning_paths.push(record);
624 }
625 saveFlowStore(dataDir, store);
626 return { applied: true, path_id: record.path_id };
627 }
628
629 if (kind === 'path_update') {
630 const pathId = applyCtx.parsed.path_id;
631 const idx = vault.learning_paths.findIndex((p) => p.path_id === pathId);
632 if (idx < 0) throw new Error('path missing at apply');
633 const existing = vault.learning_paths[idx];
634 const patch = applyCtx.parsed.patch && typeof applyCtx.parsed.patch === 'object' ? applyCtx.parsed.patch : {};
635 const nextSteps = Array.isArray(patch.steps) ? patch.steps : existing.steps;
636 const nextIndex =
637 typeof patch.current_step_index === 'number' ? patch.current_step_index : existing.current_step_index;
638 const merged = {
639 ...existing,
640 ...patch,
641 steps: nextSteps,
642 current_step_index: nextIndex,
643 step_count: nextSteps.length,
644 next_step_title: nextSteps[nextIndex].title,
645 updated: now,
646 };
647 vault.learning_paths[idx] = merged;
648 saveFlowStore(dataDir, store);
649 return { applied: true, path_id: pathId };
650 }
651
652 if (kind === 'path_archive') {
653 const pathId = applyCtx.parsed.path_id;
654 const idx = vault.learning_paths.findIndex((p) => p.path_id === pathId);
655 if (idx < 0) throw new Error('path missing at apply');
656 vault.learning_paths[idx] = {
657 ...vault.learning_paths[idx],
658 status: 'archived',
659 updated: now,
660 };
661 saveFlowStore(dataDir, store);
662 return { applied: true, path_id: pathId };
663 }
664
665 throw new Error(`unsupported path proposal_kind at apply: ${kind}`);
666 }
667
668 /**
669 * Hosted/self-hosted apply-approved entry (gate + precheck + reconcile).
670 *
671 * @param {string} dataDir
672 * @param {object} proposal
673 */
674 export function applyApprovedPathProposal(dataDir, proposal) {
675 const precheck = precheckApprovedPathProposal(dataDir, proposal);
676 if (!precheck.ok) return precheck;
677 const reconcile = reconcileApprovedPathProposal(dataDir, precheck);
678 return {
679 ok: true,
680 payload: {
681 applied: true,
682 proposal_id: proposal.proposal_id ?? null,
683 vault_id: precheck.vaultId,
684 proposal_kind: precheck.proposalKind,
685 path_id: reconcile.path_id,
686 },
687 };
688 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago