task-hosted-proposal.mjs
378 lines 12.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted task proposal parity (Phase 2G hosted route wire).
3 *
4 * Task write proposals must live in the canister proposal store so Hub Activity can
5 * list them. Approve apply runs via POST …/tasks/proposals/:id/apply-approved (gateway
6 * hook after approve) into bridge hub_flow_store.json.
7 *
8 * @see docs/TASK-WRITE-PROPOSAL-CONTRACT-2G-d.md — SD-4 review-before-write
9 */
10
11 import { parseCanisterProposalGetBody } from '../canister-proposal-response-parse.mjs';
12 import {
13 TASK_PROPOSAL_SOURCE,
14 precheckApprovedTaskProposal,
15 reconcileApprovedTaskProposal,
16 } from './task-write.mjs';
17
18 export const FM_PROPOSAL_SOURCE = 'knowtation_proposal_source';
19 export const FM_TASK_RECORD_KIND = 'task_record_kind';
20 export const FM_TASK_PROPOSAL_KIND = 'task_proposal_kind';
21 export const FM_TASK_ID = 'task_id';
22 export const FM_LOOP_ID = 'loop_id';
23 export const FM_OCCURRENCE_KEY = 'occurrence_key';
24
25 /**
26 * @param {unknown} frontmatter
27 * @returns {Record<string, unknown>}
28 */
29 export function parseProposalFrontmatter(frontmatter) {
30 if (frontmatter == null) return {};
31 if (typeof frontmatter === 'object' && !Array.isArray(frontmatter)) {
32 return /** @type {Record<string, unknown>} */ (frontmatter);
33 }
34 if (typeof frontmatter === 'string' && frontmatter.trim()) {
35 try {
36 const parsed = JSON.parse(frontmatter);
37 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
38 ? /** @type {Record<string, unknown>} */ (parsed)
39 : {};
40 } catch {
41 return {};
42 }
43 }
44 return {};
45 }
46
47 /**
48 * Embed task metadata in canister frontmatter JSON (canister has no task_meta column).
49 *
50 * @param {Record<string, unknown>|undefined|null} baseFm
51 * @param {{
52 * record_kind: string,
53 * proposal_kind: string,
54 * task_id?: string|null,
55 * loop_id?: string|null,
56 * occurrence_key?: string|null,
57 * }} taskMeta
58 * @returns {Record<string, unknown>}
59 */
60 export function mergeTaskFrontmatter(baseFm, taskMeta) {
61 const fm = {
62 ...(baseFm && typeof baseFm === 'object' && !Array.isArray(baseFm) ? baseFm : {}),
63 };
64 fm[FM_PROPOSAL_SOURCE] = TASK_PROPOSAL_SOURCE;
65 fm[FM_TASK_RECORD_KIND] = String(taskMeta.record_kind || 'task').slice(0, 32);
66 fm[FM_TASK_PROPOSAL_KIND] = String(taskMeta.proposal_kind || '').slice(0, 32);
67 if (taskMeta.task_id != null) {
68 fm[FM_TASK_ID] = String(taskMeta.task_id).slice(0, 64);
69 }
70 if (taskMeta.loop_id != null) {
71 fm[FM_LOOP_ID] = String(taskMeta.loop_id).slice(0, 64);
72 }
73 if (taskMeta.occurrence_key != null) {
74 fm[FM_OCCURRENCE_KEY] = String(taskMeta.occurrence_key).slice(0, 64);
75 }
76 return fm;
77 }
78
79 /**
80 * Map a canister proposal row into the shape `precheckApprovedTaskProposal` expects.
81 *
82 * @param {Record<string, unknown>} proposal
83 * @returns {Record<string, unknown>|null}
84 */
85 export function normalizeCanisterProposalForTaskPrecheck(proposal) {
86 if (!proposal || typeof proposal !== 'object') return null;
87
88 const fm = parseProposalFrontmatter(proposal.frontmatter);
89 const fromFm = fm[FM_PROPOSAL_SOURCE] === TASK_PROPOSAL_SOURCE;
90 const fromSource = proposal.source === TASK_PROPOSAL_SOURCE;
91 const path =
92 typeof proposal.path === 'string' && proposal.path.startsWith('meta/tasks/proposals/');
93
94 if (!fromFm && !fromSource && !path) return null;
95
96 /** @type {{ record_kind: string, proposal_kind: string, task_id?: string|null, loop_id?: string|null, occurrence_key?: string|null }} */
97 const task_meta = {
98 record_kind:
99 (typeof fm[FM_TASK_RECORD_KIND] === 'string' && fm[FM_TASK_RECORD_KIND].trim()) ||
100 (proposal.task_meta &&
101 typeof proposal.task_meta === 'object' &&
102 typeof /** @type {{ record_kind?: string }} */ (proposal.task_meta).record_kind === 'string'
103 ? /** @type {{ record_kind: string }} */ (proposal.task_meta).record_kind
104 : 'task'),
105 proposal_kind:
106 (typeof fm[FM_TASK_PROPOSAL_KIND] === 'string' && fm[FM_TASK_PROPOSAL_KIND].trim()) ||
107 (proposal.task_meta &&
108 typeof proposal.task_meta === 'object' &&
109 typeof /** @type {{ proposal_kind?: string }} */ (proposal.task_meta).proposal_kind === 'string'
110 ? /** @type {{ proposal_kind: string }} */ (proposal.task_meta).proposal_kind
111 : ''),
112 };
113
114 if (typeof fm[FM_TASK_ID] === 'string' && fm[FM_TASK_ID].trim()) {
115 task_meta.task_id = fm[FM_TASK_ID].trim();
116 } else if (
117 proposal.task_meta &&
118 typeof proposal.task_meta === 'object' &&
119 /** @type {{ task_id?: string|null }} */ (proposal.task_meta).task_id != null
120 ) {
121 task_meta.task_id = /** @type {{ task_id: string|null }} */ (proposal.task_meta).task_id;
122 }
123
124 if (typeof fm[FM_LOOP_ID] === 'string' && fm[FM_LOOP_ID].trim()) {
125 task_meta.loop_id = fm[FM_LOOP_ID].trim();
126 } else if (
127 proposal.task_meta &&
128 typeof proposal.task_meta === 'object' &&
129 /** @type {{ loop_id?: string|null }} */ (proposal.task_meta).loop_id != null
130 ) {
131 task_meta.loop_id = /** @type {{ loop_id: string|null }} */ (proposal.task_meta).loop_id;
132 }
133
134 if (typeof fm[FM_OCCURRENCE_KEY] === 'string' && fm[FM_OCCURRENCE_KEY].trim()) {
135 task_meta.occurrence_key = fm[FM_OCCURRENCE_KEY].trim();
136 } else if (
137 proposal.task_meta &&
138 typeof proposal.task_meta === 'object' &&
139 /** @type {{ occurrence_key?: string|null }} */ (proposal.task_meta).occurrence_key != null
140 ) {
141 task_meta.occurrence_key = /** @type {{ occurrence_key: string|null }} */ (proposal.task_meta)
142 .occurrence_key;
143 }
144
145 if (!task_meta.proposal_kind) {
146 try {
147 const parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
148 if (parsed && typeof parsed === 'object' && typeof parsed.proposal_kind === 'string') {
149 task_meta.proposal_kind = parsed.proposal_kind.trim();
150 }
151 } catch {
152 // ignore parse errors
153 }
154 }
155
156 if (!task_meta.proposal_kind) return null;
157
158 return {
159 ...proposal,
160 source: TASK_PROPOSAL_SOURCE,
161 task_meta,
162 };
163 }
164
165 /**
166 * POST a task proposal to the canister (hosted bridge propose path).
167 *
168 * @param {{
169 * canisterUrl: string,
170 * headers: Record<string, string>,
171 * input: {
172 * path: string,
173 * body?: string,
174 * intent?: string,
175 * frontmatter?: Record<string, unknown>,
176 * base_state_id?: string,
177 * task_meta?: {
178 * record_kind: string,
179 * proposal_kind: string,
180 * task_id?: string|null,
181 * loop_id?: string|null,
182 * occurrence_key?: string|null,
183 * },
184 * vault_id?: string,
185 * review_queue?: string,
186 * proposed_by?: string,
187 * },
188 * }} opts
189 * @returns {Promise<Record<string, unknown>>}
190 */
191 export async function createTaskProposalOnCanister(opts) {
192 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
193 if (!base) {
194 const err = new Error('CANISTER_URL required for hosted task proposals');
195 err.status = 503;
196 err.code = 'NOT_AVAILABLE';
197 throw err;
198 }
199
200 const input = opts.input;
201 const frontmatter = mergeTaskFrontmatter(input.frontmatter, input.task_meta ?? { record_kind: 'task', proposal_kind: '' });
202 /** @type {Record<string, unknown>} */
203 const payload = {
204 path: input.path,
205 body: input.body ?? '',
206 intent: input.intent ?? '',
207 frontmatter,
208 };
209 if (input.base_state_id) payload.base_state_id = input.base_state_id;
210 if (input.review_queue) payload.review_queue = input.review_queue;
211 if (input.external_ref) payload.external_ref = input.external_ref;
212
213 // E1 create-time satisfaction for admitted Task fingerprints (pending path allowed; Motoko rewrites).
214 const { applyPersonalSelfApplyEvaluationE1 } = await import('../hub-proposal-personal-self-apply.mjs');
215 const e1Body = applyPersonalSelfApplyEvaluationE1(
216 {
217 ...payload,
218 source: TASK_PROPOSAL_SOURCE,
219 task_meta: input.task_meta,
220 external_ref: input.external_ref,
221 status: 'proposed',
222 },
223 {
224 evaluatedBy: typeof input.proposed_by === 'string' ? input.proposed_by : '',
225 authorActorId: typeof input.proposed_by === 'string' ? input.proposed_by : '',
226 sessionBound: opts.sessionBound === true,
227 },
228 );
229 if (e1Body.evaluation_status === 'passed') {
230 payload.evaluation_status = 'passed';
231 if (e1Body.evaluated_by) payload.evaluated_by = e1Body.evaluated_by;
232 if (e1Body.evaluated_at) payload.evaluated_at = e1Body.evaluated_at;
233 }
234
235 const res = await fetch(`${base}/api/v1/proposals`, {
236 method: 'POST',
237 headers: {
238 Accept: 'application/json',
239 'Content-Type': 'application/json',
240 ...opts.headers,
241 },
242 body: JSON.stringify(payload),
243 });
244
245 const text = await res.text();
246 /** @type {Record<string, unknown>} */
247 let json = {};
248 try {
249 json = text ? JSON.parse(text) : {};
250 } catch {
251 json = {};
252 }
253
254 if (!res.ok) {
255 const err = new Error(
256 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
257 );
258 err.status = res.status;
259 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
260 throw err;
261 }
262
263 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
264 if (!proposalId) {
265 const err = new Error('Canister proposal create missing proposal_id');
266 err.status = 502;
267 err.code = 'BAD_GATEWAY';
268 throw err;
269 }
270
271 const now = new Date().toISOString();
272 return {
273 proposal_id: proposalId,
274 path: typeof json.path === 'string' ? json.path : input.path,
275 status: typeof json.status === 'string' ? json.status : 'proposed',
276 vault_id: input.vault_id,
277 intent: input.intent,
278 body: input.body,
279 frontmatter,
280 base_state_id: input.base_state_id,
281 external_ref: input.external_ref,
282 source: TASK_PROPOSAL_SOURCE,
283 task_meta: input.task_meta,
284 review_queue: input.review_queue,
285 proposed_by: input.proposed_by,
286 evaluation_status: e1Body.evaluation_status,
287 created_at: now,
288 updated_at: now,
289 };
290 }
291
292 /**
293 * Fetch one proposal from the canister and normalize for task apply.
294 *
295 * @param {{
296 * canisterUrl: string,
297 * headers: Record<string, string>,
298 * proposalId: string,
299 * }} opts
300 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
301 */
302 export async function fetchCanisterProposalForTask(opts) {
303 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
304 const proposalId = String(opts.proposalId || '').trim();
305 if (!base || !proposalId) {
306 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
307 }
308
309 const res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
310 method: 'GET',
311 headers: { Accept: 'application/json', ...opts.headers },
312 });
313 const text = await res.text();
314 if (!res.ok) {
315 return {
316 ok: false,
317 status: res.status === 404 ? 404 : 502,
318 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
319 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
320 };
321 }
322
323 const raw = parseCanisterProposalGetBody(proposalId, text, {});
324 const normalized = normalizeCanisterProposalForTaskPrecheck(raw);
325 if (!normalized) {
326 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a task proposal' };
327 }
328 return { ok: true, proposal: normalized };
329 }
330
331 /**
332 * Apply an approved canister task proposal to bridge hub_flow_store.json.
333 *
334 * @param {{
335 * dataDir: string,
336 * canisterUrl: string,
337 * headers: Record<string, string>,
338 * proposalId: string,
339 * requireApproved?: boolean,
340 * }} opts
341 * @returns {Promise<{ ok: true, payload: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
342 */
343 export async function applyApprovedTaskProposalFromCanister(opts) {
344 const fetched = await fetchCanisterProposalForTask({
345 canisterUrl: opts.canisterUrl,
346 headers: opts.headers,
347 proposalId: opts.proposalId,
348 });
349 if (!fetched.ok) return fetched;
350
351 const proposal = fetched.proposal;
352 if (opts.requireApproved !== false && proposal.status !== 'approved') {
353 return {
354 ok: false,
355 status: 409,
356 code: 'CONFLICT',
357 error: 'Proposal must be approved before task index apply',
358 };
359 }
360
361 const precheck = precheckApprovedTaskProposal(opts.dataDir, proposal);
362 if (!precheck.ok) {
363 return precheck;
364 }
365
366 const reconcile = reconcileApprovedTaskProposal(opts.dataDir, precheck);
367 return {
368 ok: true,
369 payload: {
370 applied: true,
371 proposal_id: opts.proposalId,
372 vault_id: precheck.vaultId,
373 proposal_kind: precheck.proposalKind,
374 task_id: reconcile.task_id ?? precheck.parsed?.task_id ?? null,
375 loop_id: reconcile.loop_id ?? precheck.parsed?.loop_id ?? null,
376 },
377 };
378 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago