ofiere-openclaw-plugin 4.54.2 → 4.56.0

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.
package/src/gateOps.ts ADDED
@@ -0,0 +1,177 @@
1
+ // src/gateOps.ts — OFIERE_GATE_OPS meta-tool
2
+ // Approve / reject manual gates blocking task dispatch in the Planning Tab.
3
+ //
4
+ // Phase 0 finding: plugin factory carries no implicit agentId in closure.
5
+ // The CALLING chief / staff agent MUST pass `agent_id` explicitly for approve/reject
6
+ // so the audit trail records "agent:<id>" in approved_by / rejected_by.
7
+ // Fallback order: args.agent_id → fallbackAgentId (gateway OFIERE_AGENT_ID env) → "user:<userId>".
8
+
9
+ import type { SupabaseClient } from "@supabase/supabase-js";
10
+
11
+ interface ToolResult {
12
+ content: Array<{ type: "text"; text: string }>;
13
+ }
14
+
15
+ function ok(data: unknown): ToolResult {
16
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
17
+ }
18
+
19
+ function err(message: string): ToolResult {
20
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }] };
21
+ }
22
+
23
+ // Bounded BFS — collect transitive successors so the agent sees cascade impact
24
+ // before rejecting. Mirrors dashboard/lib/ofie/tool-executor.ts _collectTransitiveSuccessors.
25
+ async function collectTransitiveSuccessors(
26
+ supabase: SupabaseClient,
27
+ userId: string,
28
+ rootTaskId: string,
29
+ ): Promise<{ id: string; title: string }[]> {
30
+ const visited = new Set<string>([rootTaskId]);
31
+ const out: { id: string; title: string }[] = [];
32
+ let frontier: string[] = [rootTaskId];
33
+ for (let depth = 0; depth < 20 && frontier.length > 0; depth++) {
34
+ const { data: deps } = await supabase
35
+ .from("pm_dependencies")
36
+ .select("successor_id")
37
+ .in("predecessor_id", frontier)
38
+ .eq("user_id", userId);
39
+ const next: string[] = [];
40
+ for (const d of (deps || [])) {
41
+ const sid = (d as any).successor_id as string;
42
+ if (!sid || visited.has(sid)) continue;
43
+ visited.add(sid);
44
+ next.push(sid);
45
+ }
46
+ if (next.length === 0) break;
47
+ const { data: tasks } = await supabase
48
+ .from("tasks")
49
+ .select("id, title")
50
+ .in("id", next)
51
+ .eq("user_id", userId);
52
+ for (const t of (tasks || [])) out.push({ id: (t as any).id, title: (t as any).title });
53
+ frontier = next;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function registerGateOps(
59
+ api: any,
60
+ supabase: SupabaseClient,
61
+ userId: string,
62
+ fallbackAgentId: string | undefined,
63
+ ): void {
64
+ api.registerTool({
65
+ name: "OFIERE_GATE_OPS",
66
+ label: "Ofiere Gate Operations",
67
+ description:
68
+ `Approve or reject manual gates blocking task dispatch in the Planning Tab.\n\n` +
69
+ `Actions:\n` +
70
+ `- "list_pending": List manual gates pending approval. Optional: agent_id (filter to gates whose blocked task is assigned to this agent).\n` +
71
+ `- "get": Inspect a specific gate + see the cascade impact of rejecting. Required: gate_id. ALWAYS call before "reject" so you understand what will fail.\n` +
72
+ `- "approve": Approve a gate so the blocked task can dispatch. Required: gate_id, agent_id (the calling chief/staff). Optional: comment.\n` +
73
+ `- "reject": Reject a gate — FAILS the blocked task and BLOCKS all transitive successors (cascade). Required: gate_id, agent_id, reason (min 5 chars).\n\n` +
74
+ `Why agent_id is required: plugin runtime has no implicit agent identity. The agent invoking this tool MUST pass its own id so approval audit trail records 'agent:<id>' instead of 'user:<owner_uuid>'. Falls back to gateway OFIERE_AGENT_ID env when absent.`,
75
+ parameters: {
76
+ type: "object",
77
+ required: ["action"],
78
+ properties: {
79
+ action: { type: "string", enum: ["list_pending", "get", "approve", "reject"], description: "list_pending | get | approve | reject" },
80
+ gate_id: { type: "string", description: "Gate UUID (required for get, approve, reject)" },
81
+ agent_id: { type: "string", description: "Calling agent id (required for approve, reject — recorded as 'agent:<id>' in approved_by / rejected_by). Also optional filter for list_pending." },
82
+ comment: { type: "string", description: "Optional approval comment" },
83
+ reason: { type: "string", description: "Rejection reason (required for reject, min 5 chars)" },
84
+ },
85
+ },
86
+ async execute(_id: string, params: Record<string, unknown>) {
87
+ try {
88
+ const action = params.action as string;
89
+ const resolveActor = (): string => {
90
+ const id = (params.agent_id as string) || fallbackAgentId;
91
+ return id ? `agent:${id}` : `user:${userId}`;
92
+ };
93
+
94
+ switch (action) {
95
+ case "list_pending": {
96
+ let q = supabase
97
+ .from("pm_gates")
98
+ .select("id, plan_id, plan_node_id, gate_label, task_id_blocked, created_at, tasks!inner(title, agent_id)")
99
+ .eq("user_id", userId)
100
+ .is("approved_at", null)
101
+ .is("rejected_at", null)
102
+ .order("created_at", { ascending: true });
103
+ if (params.agent_id) q = q.eq("tasks.agent_id", params.agent_id as string);
104
+ const { data, error } = await q;
105
+ if (error) return err(error.message);
106
+ return ok({ pending: data || [], count: (data || []).length });
107
+ }
108
+
109
+ case "get": {
110
+ if (!params.gate_id) return err("Missing required: gate_id");
111
+ const { data: gate, error } = await supabase
112
+ .from("pm_gates")
113
+ .select("*, tasks!inner(id, title, agent_id, status)")
114
+ .eq("id", params.gate_id as string)
115
+ .eq("user_id", userId)
116
+ .maybeSingle();
117
+ if (error) return err(error.message);
118
+ if (!gate) return err("Gate not found");
119
+ const blockedTaskId = (gate as any).task_id_blocked as string;
120
+ const downstream = await collectTransitiveSuccessors(supabase, userId, blockedTaskId);
121
+ const blockedTitle = (gate as any).tasks?.title || "blocked task";
122
+ return ok({
123
+ gate,
124
+ impact_on_reject: {
125
+ failed_task: { id: blockedTaskId, title: blockedTitle },
126
+ blocked_downstream: downstream,
127
+ summary: `Rejecting will FAIL '${blockedTitle}' and BLOCK ${downstream.length} downstream task${downstream.length === 1 ? "" : "s"}.`,
128
+ },
129
+ });
130
+ }
131
+
132
+ case "approve": {
133
+ if (!params.gate_id) return err("Missing required: gate_id");
134
+ if (!params.agent_id && !fallbackAgentId) return err("Missing required: agent_id (calling agent identity — plugin has no implicit agent context)");
135
+ const actor = resolveActor();
136
+ const { data, error } = await supabase
137
+ .from("pm_gates")
138
+ .update({
139
+ approved_at: new Date().toISOString(),
140
+ approved_by: actor,
141
+ approval_comment: (params.comment as string) ?? null,
142
+ })
143
+ .eq("id", params.gate_id as string)
144
+ .eq("user_id", userId)
145
+ .is("approved_at", null)
146
+ .is("rejected_at", null)
147
+ .select("id");
148
+ if (error) return err(error.message);
149
+ if (!data || data.length === 0) return err("Gate not found or already resolved");
150
+ return ok({ approved: true, gate_id: params.gate_id, approved_by: actor });
151
+ }
152
+
153
+ case "reject": {
154
+ if (!params.gate_id) return err("Missing required: gate_id");
155
+ if (!params.agent_id && !fallbackAgentId) return err("Missing required: agent_id (calling agent identity — plugin has no implicit agent context)");
156
+ const reason = ((params.reason as string) || "").trim();
157
+ if (reason.length < 5) return err("Missing required: reason (min 5 chars) — rejection requires explanation");
158
+ const actor = resolveActor();
159
+ const { data, error } = await supabase.rpc("cascade_reject_gate", {
160
+ p_gate_id: params.gate_id as string,
161
+ p_user_id: userId,
162
+ p_reason: reason,
163
+ p_actor: actor,
164
+ });
165
+ if (error) return err(error.message);
166
+ return ok({ rejected: true, gate_id: params.gate_id, rejected_by: actor, result: data });
167
+ }
168
+
169
+ default:
170
+ return err(`Unknown gate action: ${action}`);
171
+ }
172
+ } catch (e) {
173
+ return err(e instanceof Error ? e.message : String(e));
174
+ }
175
+ },
176
+ });
177
+ }
@@ -0,0 +1,197 @@
1
+ // MIRROR — sync manually with dashboard/lib/pm/planExecute.ts
2
+ // Plugin is a separate npm package and cannot import from dashboard/.
3
+ // If you edit either file, update both. Helper algorithm must stay byte-identical
4
+ // across runtimes so pm_dependencies + pm_gates rows have the same shape regardless
5
+ // of whether the plan was executed via dashboard / native ofiere_plan_ops / plugin OFIERE_PLAN_OPS.
6
+
7
+ /**
8
+ * Shared plan-execute helper — converts a tree of PlanNodes into concrete
9
+ * pm_dependencies edges + pm_gates rows.
10
+ *
11
+ * Single source of truth for gate semantics (Option A skip-through + Option B
12
+ * OR-join via group_id + Option C manual gates via pm_gates rows).
13
+ */
14
+
15
+ // Minimum PlanNode shape this helper needs. Mirrors the subset of
16
+ // dashboard/lib/pm/planning-types.ts PlanNode used by the algorithm.
17
+ export interface PlanNode {
18
+ id: string;
19
+ type: 'task' | 'gate' | 'milestone';
20
+ title?: string;
21
+ children: PlanNode[];
22
+ parallel?: boolean;
23
+ gateCondition?: 'all_predecessors_complete' | 'any_predecessor_complete' | 'manual';
24
+ }
25
+
26
+ export interface ExpandedEdge {
27
+ predTaskId: string;
28
+ succTaskId: string;
29
+ groupId: string | null; // non-null = OR-join group key (shared across same-group edges)
30
+ lagDays: number;
31
+ dependencyType: 'finish_to_start';
32
+ }
33
+
34
+ export interface ExpandedGate {
35
+ planNodeId: string;
36
+ gateLabel: string;
37
+ gateCondition: 'manual'; // helper only emits rows for manual gates
38
+ taskIdBlocked: string;
39
+ }
40
+
41
+ export interface ExpandResult {
42
+ edges: ExpandedEdge[];
43
+ gates: ExpandedGate[];
44
+ emptyGates: string[]; // plan_node_id of gates whose descendants have zero tasks (diagnostic)
45
+ }
46
+
47
+ interface LeafHit {
48
+ taskId: string;
49
+ gateChain: PlanNode[]; // gates traversed from the leaf up to (and excluding) the starting node
50
+ }
51
+
52
+ /**
53
+ * Walk plan tree → emit concrete predecessor edges + manual-gate rows.
54
+ *
55
+ * Predecessor resolution (per task/milestone node, walking UP from succ):
56
+ * - Root + idx=0 → no predecessor
57
+ * - Root + idx>0 → leaves of rootSibling[idx-1]
58
+ * - Parent.parallel = true + parent is task → predecessor = parent
59
+ * - Parent.parallel = true + parent is gate → recurse upward through gate
60
+ * - Non-parallel parent + idx>0 → leaves of parent.children[idx-1]
61
+ * - Non-parallel parent + idx=0 → parent (recurse if gate)
62
+ *
63
+ * Group-id rule: if any gate on the traversal path has
64
+ * `gateCondition='any_predecessor_complete'`, all resolved sources for this
65
+ * successor share one UUID-flavored group_id (dispatcher treats as OR-join).
66
+ *
67
+ * Manual-gate emission: for each task/milestone, walk UP through `parentOf`
68
+ * chain and emit one pm_gates row for every `manual` gate ancestor. Nested
69
+ * manual gates produce N rows; dispatcher AND-joins them.
70
+ */
71
+ export function expandPlanToDependencyEdges(
72
+ rootNodes: PlanNode[],
73
+ idMap: Map<string, string>,
74
+ ): ExpandResult {
75
+ const edges: ExpandedEdge[] = [];
76
+ const gates: ExpandedGate[] = [];
77
+ const emptyGates: string[] = [];
78
+ const seenEdgeKey = new Set<string>();
79
+ const seenGateKey = new Set<string>();
80
+
81
+ const parentOf = new Map<string, PlanNode | null>();
82
+ const indexInParent = new Map<string, number>();
83
+ const allNodes: PlanNode[] = [];
84
+
85
+ function indexTree(nodes: PlanNode[], parent: PlanNode | null) {
86
+ nodes.forEach((n, i) => {
87
+ parentOf.set(n.id, parent);
88
+ indexInParent.set(n.id, i);
89
+ allNodes.push(n);
90
+ if (Array.isArray(n.children) && n.children.length > 0) indexTree(n.children, n);
91
+ });
92
+ }
93
+ indexTree(rootNodes, null);
94
+
95
+ // Resolve a node (possibly a gate) to its task-leaf set, tracking gates traversed.
96
+ function leavesOf(n: PlanNode, gatesAbove: PlanNode[]): LeafHit[] {
97
+ if (n.type === 'task' || n.type === 'milestone') {
98
+ const tid = idMap.get(n.id);
99
+ return tid ? [{ taskId: tid, gateChain: gatesAbove }] : [];
100
+ }
101
+ if (n.type === 'gate') {
102
+ const nextGates = [...gatesAbove, n];
103
+ const out: LeafHit[] = [];
104
+ for (const c of (n.children || [])) out.push(...leavesOf(c, nextGates));
105
+ if (out.length === 0) emptyGates.push(n.id);
106
+ return out;
107
+ }
108
+ return [];
109
+ }
110
+
111
+ // Walk UP from succ to find predecessor leaves + whether any gate on the path is OR-join.
112
+ function predecessorsFor(succ: PlanNode): { sources: LeafHit[]; orJoin: boolean } {
113
+ let orJoin = false;
114
+ let cursor: PlanNode | null = succ;
115
+
116
+ while (cursor) {
117
+ const parent: PlanNode | null = parentOf.get(cursor.id) ?? null;
118
+ const idx = indexInParent.get(cursor.id) ?? 0;
119
+
120
+ if (!parent) {
121
+ // root-level sibling chain
122
+ if (idx === 0) return { sources: [], orJoin };
123
+ return { sources: leavesOf(rootNodes[idx - 1], []), orJoin };
124
+ }
125
+
126
+ // OR-detection happens whenever we traverse into a gate ancestor
127
+ if (parent.type === 'gate' && parent.gateCondition === 'any_predecessor_complete') {
128
+ orJoin = true;
129
+ }
130
+
131
+ if (parent.parallel) {
132
+ // parallel fan-out: predecessor IS the parent
133
+ if (parent.type === 'gate') { cursor = parent; continue; }
134
+ const pid = idMap.get(parent.id);
135
+ return pid ? { sources: [{ taskId: pid, gateChain: [] }], orJoin } : { sources: [], orJoin };
136
+ }
137
+
138
+ if (idx > 0) {
139
+ // non-parallel: pred is the previous sibling (may itself be a gate)
140
+ return { sources: leavesOf(parent.children[idx - 1], []), orJoin };
141
+ }
142
+
143
+ // first child of non-parallel parent → pred is the parent (recurse if gate)
144
+ if (parent.type === 'gate') { cursor = parent; continue; }
145
+ const pid = idMap.get(parent.id);
146
+ return pid ? { sources: [{ taskId: pid, gateChain: [] }], orJoin } : { sources: [], orJoin };
147
+ }
148
+
149
+ return { sources: [], orJoin };
150
+ }
151
+
152
+ for (const node of allNodes) {
153
+ if (node.type !== 'task' && node.type !== 'milestone') continue;
154
+ const succTaskId = idMap.get(node.id);
155
+ if (!succTaskId) continue;
156
+
157
+ const { sources, orJoin: orJoinFromWalk } = predecessorsFor(node);
158
+ // OR-join also fires if the resolved leaves passed THROUGH an OR gate on their way up
159
+ // (e.g., succ is a root-sibling downstream of an OR gate — leavesOf walks INTO the gate).
160
+ const orJoinFromLeaves = sources.some(s => s.gateChain.some(g => g.type === 'gate' && g.gateCondition === 'any_predecessor_complete'));
161
+ const orJoin = orJoinFromWalk || orJoinFromLeaves;
162
+ const groupId = (orJoin && sources.length > 1) ? `grp-${succTaskId}-${node.id.slice(-6)}` : null;
163
+
164
+ for (const s of sources) {
165
+ const key = `${s.taskId}->${succTaskId}|${groupId ?? ''}`;
166
+ if (seenEdgeKey.has(key)) continue;
167
+ seenEdgeKey.add(key);
168
+ edges.push({
169
+ predTaskId: s.taskId,
170
+ succTaskId,
171
+ groupId,
172
+ lagDays: 0,
173
+ dependencyType: 'finish_to_start',
174
+ });
175
+ }
176
+
177
+ // Manual-gate rows: walk UP through every ancestor gate; emit one row per manual gate.
178
+ let cur: PlanNode | null = parentOf.get(node.id) || null;
179
+ while (cur) {
180
+ if (cur.type === 'gate' && cur.gateCondition === 'manual') {
181
+ const k = `${cur.id}::${succTaskId}`;
182
+ if (!seenGateKey.has(k)) {
183
+ seenGateKey.add(k);
184
+ gates.push({
185
+ planNodeId: cur.id,
186
+ gateLabel: cur.title || 'Manual gate',
187
+ gateCondition: 'manual',
188
+ taskIdBlocked: succTaskId,
189
+ });
190
+ }
191
+ }
192
+ cur = parentOf.get(cur.id) || null;
193
+ }
194
+ }
195
+
196
+ return { edges, gates, emptyGates };
197
+ }