task-hosted-proposal.mjs
353 lines 11.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 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
212 const res = await fetch(`${base}/api/v1/proposals`, {
213 method: 'POST',
214 headers: {
215 Accept: 'application/json',
216 'Content-Type': 'application/json',
217 ...opts.headers,
218 },
219 body: JSON.stringify(payload),
220 });
221
222 const text = await res.text();
223 /** @type {Record<string, unknown>} */
224 let json = {};
225 try {
226 json = text ? JSON.parse(text) : {};
227 } catch {
228 json = {};
229 }
230
231 if (!res.ok) {
232 const err = new Error(
233 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
234 );
235 err.status = res.status;
236 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
237 throw err;
238 }
239
240 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
241 if (!proposalId) {
242 const err = new Error('Canister proposal create missing proposal_id');
243 err.status = 502;
244 err.code = 'BAD_GATEWAY';
245 throw err;
246 }
247
248 const now = new Date().toISOString();
249 return {
250 proposal_id: proposalId,
251 path: typeof json.path === 'string' ? json.path : input.path,
252 status: typeof json.status === 'string' ? json.status : 'proposed',
253 vault_id: input.vault_id,
254 intent: input.intent,
255 body: input.body,
256 frontmatter,
257 base_state_id: input.base_state_id,
258 source: TASK_PROPOSAL_SOURCE,
259 task_meta: input.task_meta,
260 review_queue: input.review_queue,
261 proposed_by: input.proposed_by,
262 created_at: now,
263 updated_at: now,
264 };
265 }
266
267 /**
268 * Fetch one proposal from the canister and normalize for task apply.
269 *
270 * @param {{
271 * canisterUrl: string,
272 * headers: Record<string, string>,
273 * proposalId: string,
274 * }} opts
275 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
276 */
277 export async function fetchCanisterProposalForTask(opts) {
278 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
279 const proposalId = String(opts.proposalId || '').trim();
280 if (!base || !proposalId) {
281 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
282 }
283
284 const res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
285 method: 'GET',
286 headers: { Accept: 'application/json', ...opts.headers },
287 });
288 const text = await res.text();
289 if (!res.ok) {
290 return {
291 ok: false,
292 status: res.status === 404 ? 404 : 502,
293 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
294 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
295 };
296 }
297
298 const raw = parseCanisterProposalGetBody(proposalId, text, {});
299 const normalized = normalizeCanisterProposalForTaskPrecheck(raw);
300 if (!normalized) {
301 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a task proposal' };
302 }
303 return { ok: true, proposal: normalized };
304 }
305
306 /**
307 * Apply an approved canister task proposal to bridge hub_flow_store.json.
308 *
309 * @param {{
310 * dataDir: string,
311 * canisterUrl: string,
312 * headers: Record<string, string>,
313 * proposalId: string,
314 * requireApproved?: boolean,
315 * }} opts
316 * @returns {Promise<{ ok: true, payload: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
317 */
318 export async function applyApprovedTaskProposalFromCanister(opts) {
319 const fetched = await fetchCanisterProposalForTask({
320 canisterUrl: opts.canisterUrl,
321 headers: opts.headers,
322 proposalId: opts.proposalId,
323 });
324 if (!fetched.ok) return fetched;
325
326 const proposal = fetched.proposal;
327 if (opts.requireApproved !== false && proposal.status !== 'approved') {
328 return {
329 ok: false,
330 status: 409,
331 code: 'CONFLICT',
332 error: 'Proposal must be approved before task index apply',
333 };
334 }
335
336 const precheck = precheckApprovedTaskProposal(opts.dataDir, proposal);
337 if (!precheck.ok) {
338 return precheck;
339 }
340
341 const reconcile = reconcileApprovedTaskProposal(opts.dataDir, precheck);
342 return {
343 ok: true,
344 payload: {
345 applied: true,
346 proposal_id: opts.proposalId,
347 vault_id: precheck.vaultId,
348 proposal_kind: precheck.proposalKind,
349 task_id: reconcile.task_id ?? precheck.parsed?.task_id ?? null,
350 loop_id: reconcile.loop_id ?? precheck.parsed?.loop_id ?? null,
351 },
352 };
353 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago