From ee693da2ca152011d8bddf205cd5d44488335ea1 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sat, 18 Jul 2026 12:10:00 -0700 Subject: [PATCH] Add schema-driven project task creation --- .../src/component-runtime.ts | 34 ++++++++++ .../src/codegen-ts-runtime.ts | 6 ++ .../src/project-component.tsx | 66 +++++++++++++++++++ .../descriptor.quixos-package.txtpb | 13 ++++ .../project-runtime/src/project.impl.ts | 31 ++++++++- .../quixos.todo.Project/schema.camino.proto | 9 +++ 6 files changed, 158 insertions(+), 1 deletion(-) diff --git a/quixos-instance/camino-visualizer/src/component-runtime.ts b/quixos-instance/camino-visualizer/src/component-runtime.ts index 9ea9004..29fa83e 100644 --- a/quixos-instance/camino-visualizer/src/component-runtime.ts +++ b/quixos-instance/camino-visualizer/src/component-runtime.ts @@ -105,6 +105,40 @@ const requireRuntime = () => { return runtimeConfig; }; +export const callObjectMethod = async ( + object: ObjectRef, + methodName: string, + input: Record = {}, +): Promise => { + const runtime = requireRuntime(); + const [objectResponse, classesResponse] = await Promise.all([ + runtime.camino.getObject({ objectId: object }), + runtime.camino.listClasses({}), + ]); + const classId = objectResponse.object?.classId; + if (!classId) { + throw new Error(`Unknown Camino object ${object}`); + } + const schema = classesResponse.classes.find( + (entry) => entry.schema?.id && symbolKey(entry.schema.id) === classId, + )?.schema; + const method = schema?.methods.find((entry) => entry.name === methodName); + if (!method?.function) { + throw new Error(`${classId} has no method ${methodName}`); + } + const response = await runtime.orch.invokeFunction({ + function: functionToRequest(method.function), + objectId: object, + input: Object.fromEntries( + Object.entries(input).map(([name, value]) => [name, jsToProtoValue(value)]), + ), + }); + if (!response.ok) { + throw new Error(response.error || `${classId}.${methodName} failed`); + } + return valueToJs(response.result) as Result; +}; + const bytesToBase64 = (value: Uint8Array) => { let binary = ""; for (const byte of value) { diff --git a/quixos-instance/packages/camino-package-runtime/src/codegen-ts-runtime.ts b/quixos-instance/packages/camino-package-runtime/src/codegen-ts-runtime.ts index cf09c8a..96f525f 100644 --- a/quixos-instance/packages/camino-package-runtime/src/codegen-ts-runtime.ts +++ b/quixos-instance/packages/camino-package-runtime/src/codegen-ts-runtime.ts @@ -612,6 +612,12 @@ const reactRuntimeDeclaration = `${generatedWarning}declare module "@quixos/cami forObject: ForObject; } & RenderProps & ReactComponentHostProps) => any; + export const callObjectMethod: ( + object: ObjectRef, + methodName: string, + input?: Record, + ) => Promise; + export const h: (...args: any[]) => any; export const useLiveField: ( field: LiveFieldProp, diff --git a/quixos-instance/packages/project-react-runtime/src/project-component.tsx b/quixos-instance/packages/project-react-runtime/src/project-component.tsx index edb5438..8c7d660 100644 --- a/quixos-instance/packages/project-react-runtime/src/project-component.tsx +++ b/quixos-instance/packages/project-react-runtime/src/project-component.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { + callObjectMethod, h, type ObjectRef, type ReactComponentImplementationProps, @@ -30,11 +31,31 @@ export default function ProjectComponent({ render, }: ProjectComponentProps) { const [selectedTask, setSelectedTask] = useState(null); + const [newTaskTitle, setNewTaskTitle] = useState(""); + const [addingTask, setAddingTask] = useState(false); + const [addTaskError, setAddTaskError] = useState(""); const handleTaskAction = (task: TaskRef, action: TodoReactComponentAction) => { if (action.type === "SELECT") { setSelectedTask(task); } }; + const addTask = async () => { + if (addingTask) { + return; + } + setAddingTask(true); + setAddTaskError(""); + try { + await callObjectMethod(camino.project_id, "addTask", { + title: newTaskTitle, + }); + setNewTaskTitle(""); + } catch (error) { + setAddTaskError(error instanceof Error ? error.message : String(error)); + } finally { + setAddingTask(false); + } + }; return (
+
{ + event.preventDefault(); + void addTask(); + }} + style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: "8px" }} + > + setNewTaskTitle(event.currentTarget.value)} + placeholder="New task" + disabled={addingTask} + style={{ + minWidth: 0, + border: "1px solid #d4d4d8", + borderRadius: "6px", + fontSize: "14px", + padding: "8px 10px", + }} + /> + + {addTaskError ? ( +
+ {addTaskError} +
+ ) : null} +
{camino.tasks.map((task) => ( { + const requestedTitle = protoValueToJs(context.request.input.title); + const title = + typeof requestedTitle === "string" && requestedTitle.trim().length > 0 + ? requestedTitle.trim() + : "Untitled task"; + const created = await context.camino.createObject({ + classId: "quixos.todo:Task:1:", + fields: { + title: jsToProtoValue(title), + status: jsToProtoValue("OPEN"), + priority: jsToProtoValue("NORMAL"), + }, + }); + if (!created.object) { + throw new Error("Camino did not return the created Task"); + } + await context.camino.addEdge({ + fromObjectId: context.object.id, + sourceField: "tasks", + toObjectId: created.object.id, + }); + return { objectId: created.object.id }; +}; + const stringField = ( fields: Record, fieldName: string, diff --git a/quixos-instance/schemas/quixos.todo.Project/schema.camino.proto b/quixos-instance/schemas/quixos.todo.Project/schema.camino.proto index 3c9f77b..75acb62 100644 --- a/quixos-instance/schemas/quixos.todo.Project/schema.camino.proto +++ b/quixos-instance/schemas/quixos.todo.Project/schema.camino.proto @@ -20,6 +20,15 @@ message Project { } }; + option (camino.method) = { + name: "addTask" + function: { + package_namespace: "quixos.todo" + package_name: "project-runtime" + symbol: "addTask" + } + }; + string name = 1 [ (camino.display_label) = true, (camino.conflict) = PRESERVE_CONFLICTS