@ctrl-spc/cs 0.7.3 → 0.7.4
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/dist/companion.js +178 -18
- package/dist/daemon.js +103 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/mcp.js +59 -286
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +22 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +802 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
package/dist/workflows.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { absolutePathToken } from './local-paths.js';
|
|
1
2
|
async function read(query, subject) {
|
|
2
3
|
const { data, error } = await query;
|
|
3
4
|
if (error)
|
|
@@ -5,29 +6,31 @@ async function read(query, subject) {
|
|
|
5
6
|
return data ?? [];
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
|
-
* A workflow, its stages in order,
|
|
9
|
+
* A workflow, its stages in order, the exits off them and its branches.
|
|
9
10
|
*
|
|
10
11
|
* SORTED HERE RATHER THAN TRUSTED, which is the web reader's own rule: order is
|
|
11
12
|
* the whole point of a workflow, positions are deliberately not unique (a
|
|
12
13
|
* reorder rewrites the set), and a tie has to break the same way twice.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* SEPARATE READS RATHER THAN ONE EMBED. `cliv2_workflow_branches` carries two
|
|
16
|
+
* foreign keys into `cliv2_workflows` (`workflow_id` and `runs_workflow_id`),
|
|
17
|
+
* so a nested embed would need disambiguating by constraint name, which couples
|
|
18
|
+
* this read to a constraint's spelling for the sake of saving a round trip on a
|
|
19
|
+
* tool call an agent makes once.
|
|
19
20
|
*/
|
|
20
21
|
export async function readWorkflow(client, workflowId) {
|
|
21
|
-
const found = await read(client.from('cliv2_workflows').select('id, name, description, ending').eq('id', workflowId), `workflow ${workflowId}`);
|
|
22
|
+
const found = await read(client.from('cliv2_workflows').select('id, name, description, ending, archived_at').eq('id', workflowId), `workflow ${workflowId}`);
|
|
22
23
|
if (found.length === 0) {
|
|
23
24
|
throw new Error(`could not read workflow ${workflowId}: there is no such row, or it is not yours`);
|
|
24
25
|
}
|
|
25
26
|
const row = found[0];
|
|
26
|
-
const [links, exits] = await Promise.all([
|
|
27
|
+
const [links, exits, branches] = await Promise.all([
|
|
27
28
|
read(client.from('cliv2_workflow_stages_in_workflow').select('stage_id, position')
|
|
28
29
|
.eq('workflow_id', workflowId), `the stages of workflow ${workflowId}`),
|
|
29
30
|
read(client.from('cliv2_workflow_stage_exits').select('stage_id, to_stage_id, condition, position')
|
|
30
31
|
.eq('workflow_id', workflowId), `the exits of workflow ${workflowId}`),
|
|
32
|
+
read(client.from('cliv2_workflow_branches').select('when_condition, runs_workflow_id, position')
|
|
33
|
+
.eq('workflow_id', workflowId), `the branches of workflow ${workflowId}`),
|
|
31
34
|
]);
|
|
32
35
|
const ordered = [...links].sort((a, b) => a.position - b.position || a.stage_id.localeCompare(b.stage_id));
|
|
33
36
|
const stageRows = ordered.length === 0 ? [] : await read(client.from('cliv2_workflow_stages').select('id, name, description, body')
|
|
@@ -40,6 +43,7 @@ export async function readWorkflow(client, workflowId) {
|
|
|
40
43
|
/* Defaulted here as well as in the column, for the web reader's reason: a
|
|
41
44
|
workflow written before the ending existed reads back as 'end'. */
|
|
42
45
|
ending: row.ending ?? 'end',
|
|
46
|
+
archivedAt: row.archived_at ?? null,
|
|
43
47
|
stages: ordered.map((link) => {
|
|
44
48
|
const stage = byId.get(link.stage_id);
|
|
45
49
|
/* A LISTED STAGE THAT DID NOT COME BACK IS AN ERROR, NEVER A GAP. The
|
|
@@ -64,5 +68,189 @@ export async function readWorkflow(client, workflowId) {
|
|
|
64
68
|
toStageId: exit.to_stage_id,
|
|
65
69
|
condition: exit.condition,
|
|
66
70
|
})),
|
|
71
|
+
branches: [...branches]
|
|
72
|
+
.sort((a, b) => a.position - b.position || a.runs_workflow_id.localeCompare(b.runs_workflow_id))
|
|
73
|
+
.map((branch) => ({ when: branch.when_condition, runsWorkflowId: branch.runs_workflow_id })),
|
|
67
74
|
};
|
|
68
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a whole workflow in one transaction, through `cliv2_build_workflow`.
|
|
78
|
+
*
|
|
79
|
+
* ═══ THE RPC IS THE WRITER, AND THAT IS THE POINT. ═══ A workflow is a row, a
|
|
80
|
+
* row per stage, a join row per stage and a row per exit, and half of that
|
|
81
|
+
* written is not a workflow. The function is `security definer` and re-asserts
|
|
82
|
+
* the `workflow:create` grant itself, orders the stages, and lets the backward
|
|
83
|
+
* only trigger refuse a forward jump. Its errors come back verbatim behind one
|
|
84
|
+
* sentence naming what was being made, so a refusal an agent has not seen
|
|
85
|
+
* before still says which workflow failed.
|
|
86
|
+
*
|
|
87
|
+
* ═══ AND NO ABSOLUTE LOCAL PATH GOES WITH IT. ═══ `/workflows` renders every
|
|
88
|
+
* one of these strings in hosted browser JS, so the same sweep `mcp.ts` runs
|
|
89
|
+
* over its own prose runs here, before the call rather than after it.
|
|
90
|
+
*/
|
|
91
|
+
export async function buildWorkflow(client, input) {
|
|
92
|
+
const prose = [['name', input.name], ['description', input.description]];
|
|
93
|
+
input.stages.forEach((stage, i) => {
|
|
94
|
+
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
95
|
+
});
|
|
96
|
+
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
|
97
|
+
for (const [field, value] of prose) {
|
|
98
|
+
const token = absolutePathToken(value);
|
|
99
|
+
if (token) {
|
|
100
|
+
throw new Error(`could not create workflow ${input.name}: ${field} contains an absolute local path ("${token}"). `
|
|
101
|
+
+ 'It is rendered by hosted browser JS and must never leave this machine; name files '
|
|
102
|
+
+ 'repo-relative. Nothing was written.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const { data, error } = await client.rpc('cliv2_build_workflow', {
|
|
106
|
+
p_org_id: input.orgId,
|
|
107
|
+
p_name: input.name,
|
|
108
|
+
p_description: input.description,
|
|
109
|
+
p_stages: input.stages,
|
|
110
|
+
p_exits: input.exits,
|
|
111
|
+
p_branches: [],
|
|
112
|
+
p_from_agent: input.fromAgent,
|
|
113
|
+
});
|
|
114
|
+
if (error)
|
|
115
|
+
throw new Error(`could not create workflow ${input.name}: ${error.message}`);
|
|
116
|
+
if (typeof data !== 'string' || data === '') {
|
|
117
|
+
throw new Error(`could not create workflow ${input.name}: the build returned no id`);
|
|
118
|
+
}
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reword one stage: its name, one-line description, or whole document.
|
|
123
|
+
*
|
|
124
|
+
* ═══ ONE ROW, THROUGH RLS, THE WAY /workflows DOES IT. ═══ The update policy on
|
|
125
|
+
* the stage table is the `workflow_stage:update` grant, the same one the web's
|
|
126
|
+
* `updateWorkflowStage` writes through, so the agent can reword exactly what the
|
|
127
|
+
* person it runs for can. `org_id`, `created_by` and `from_agent` are not sent:
|
|
128
|
+
* the guard freezes them, and the stage stays attributed to whoever wrote it.
|
|
129
|
+
* (Ruling 40 withheld stage prose from agents; the north star of 2026-08-28
|
|
130
|
+
* grants it, with the disclosure below as the mitigation.)
|
|
131
|
+
*
|
|
132
|
+
* ═══ A STAGE IS A SHARED ROW, ═══ so the result names every other live workflow
|
|
133
|
+
* that holds it: the caller has just changed those too. Every holder is in the
|
|
134
|
+
* stage's org (the link table's cross-org guard), a caller who could update the
|
|
135
|
+
* stage holds a grant there and so is a member, and both select policies are
|
|
136
|
+
* membership, so the list is complete.
|
|
137
|
+
*/
|
|
138
|
+
export async function rewordStage(client, input) {
|
|
139
|
+
const prose = [
|
|
140
|
+
['name', input.name], ['description', input.description], ['body', input.body],
|
|
141
|
+
];
|
|
142
|
+
for (const [field, value] of prose) {
|
|
143
|
+
const token = value === undefined ? null : absolutePathToken(value);
|
|
144
|
+
if (token) {
|
|
145
|
+
throw new Error(`could not reword the stage: ${field} contains an absolute local path ("${token}"). `
|
|
146
|
+
+ 'It is rendered by hosted browser JS and must never leave this machine; name files '
|
|
147
|
+
+ 'repo-relative. Nothing was written.');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const patch = {};
|
|
151
|
+
if (input.name !== undefined)
|
|
152
|
+
patch.name = input.name;
|
|
153
|
+
if (input.description !== undefined)
|
|
154
|
+
patch.description = input.description;
|
|
155
|
+
if (input.body !== undefined)
|
|
156
|
+
patch.body = input.body;
|
|
157
|
+
const { data, error } = await client.from('cliv2_workflow_stages')
|
|
158
|
+
.update(patch).eq('id', input.stageId).select('id');
|
|
159
|
+
if (error)
|
|
160
|
+
throw new Error(`could not reword the stage: ${error.message}. Nothing was written.`);
|
|
161
|
+
if (!data || data.length === 0) {
|
|
162
|
+
throw new Error('No permission to edit this stage, or it no longer exists.');
|
|
163
|
+
}
|
|
164
|
+
/* READ AFTER THE WRITE, because these describe a change that has happened. A
|
|
165
|
+
failed disclosure read throws with its own subject, which does not claim
|
|
166
|
+
nothing was written. */
|
|
167
|
+
const links = await read(client.from('cliv2_workflow_stages_in_workflow').select('workflow_id').eq('stage_id', input.stageId), `the workflows that hold stage ${input.stageId}`);
|
|
168
|
+
const others = [...new Set(links.map((link) => link.workflow_id))].filter((id) => id !== input.workflowId);
|
|
169
|
+
if (others.length === 0)
|
|
170
|
+
return { alsoIn: [] };
|
|
171
|
+
const live = await read(client.from('cliv2_workflows').select('name').in('id', others).is('archived_at', null).order('name'), 'the other workflows that hold this stage');
|
|
172
|
+
return { alsoIn: live.map((workflow) => workflow.name) };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Replace a workflow's shape in one transaction, through `cliv2_edit_workflow`.
|
|
176
|
+
*
|
|
177
|
+
* ═══ THE RPC IS THE WRITER, for `buildWorkflow`'s reasons and one more. ═══ The
|
|
178
|
+
* live-run check must see every member's runs, and `cliv2_stages` is
|
|
179
|
+
* owner-scoped RLS, so only a definer function can refuse on a teammate's
|
|
180
|
+
* behalf. Its refusals come back verbatim behind one sentence naming what was
|
|
181
|
+
* being edited.
|
|
182
|
+
*
|
|
183
|
+
* ═══ BRANCHES ARE PASSED BACK AS THEY WERE READ. ═══ The RPC replaces them from
|
|
184
|
+
* `p_branches`, and nothing in v3 sets one, so the caller hands back the list
|
|
185
|
+
* `readWorkflow` read a moment before. A branch written between that read and
|
|
186
|
+
* this call is lost, which is the posture the web's `replaceStructure` records
|
|
187
|
+
* for itself.
|
|
188
|
+
*
|
|
189
|
+
* ═══ NULL NAME OR DESCRIPTION MEANS UNCHANGED, ═══ which is the RPC's own
|
|
190
|
+
* contract: an agent adding a stage does not restate a name it is not touching.
|
|
191
|
+
*/
|
|
192
|
+
export async function editWorkflow(client, input) {
|
|
193
|
+
const prose = [['name', input.name], ['description', input.description]];
|
|
194
|
+
input.stages.forEach((stage, i) => {
|
|
195
|
+
if ('stage_id' in stage)
|
|
196
|
+
return;
|
|
197
|
+
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
198
|
+
});
|
|
199
|
+
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
|
200
|
+
for (const [field, value] of prose) {
|
|
201
|
+
const token = value === null ? null : absolutePathToken(value);
|
|
202
|
+
if (token) {
|
|
203
|
+
throw new Error(`could not edit the workflow: ${field} contains an absolute local path ("${token}"). `
|
|
204
|
+
+ 'It is rendered by hosted browser JS and must never leave this machine; name files '
|
|
205
|
+
+ 'repo-relative. Nothing was written.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const { data, error } = await client.rpc('cliv2_edit_workflow', {
|
|
209
|
+
p_workflow_id: input.workflowId,
|
|
210
|
+
p_name: input.name,
|
|
211
|
+
p_description: input.description,
|
|
212
|
+
p_stages: input.stages,
|
|
213
|
+
p_exits: input.exits,
|
|
214
|
+
p_branches: input.branches.map((branch) => ({ when: branch.when, runs_workflow_id: branch.runsWorkflowId })),
|
|
215
|
+
p_from_agent: input.fromAgent,
|
|
216
|
+
});
|
|
217
|
+
if (error)
|
|
218
|
+
throw new Error(`could not edit the workflow: ${error.message}`);
|
|
219
|
+
if (typeof data !== 'number') {
|
|
220
|
+
throw new Error('the edit was applied, but the stage count did not come back. Read the workflow to see its shape.');
|
|
221
|
+
}
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Copy a workflow into a new one with no runs, through `cliv2_duplicate_workflow`.
|
|
226
|
+
*
|
|
227
|
+
* ═══ THE WAY PAST A WORKFLOW IN USE. ═══ `cliv2_edit_workflow` refuses to
|
|
228
|
+
* re-shape a workflow any work item is running, and names this as the way out:
|
|
229
|
+
* the original keeps its runs, the copy takes the change.
|
|
230
|
+
*
|
|
231
|
+
* ═══ THE STAGES ARE NOT COPIED. ═══ The copy links the same stage rows (a stage
|
|
232
|
+
* is shared, ruling 6), so rewording one still rewords both; only the shape is
|
|
233
|
+
* the copy's own. Nothing is read before the call: the RPC's own refusals
|
|
234
|
+
* (archived, not found in an organization you belong to, no stages, no grant)
|
|
235
|
+
* are complete and are the words v2 agents already see, so they come back
|
|
236
|
+
* verbatim behind one sentence rather than being restated here.
|
|
237
|
+
*/
|
|
238
|
+
export async function duplicateWorkflow(client, input) {
|
|
239
|
+
const token = input.name === null ? null : absolutePathToken(input.name);
|
|
240
|
+
if (token) {
|
|
241
|
+
throw new Error(`could not copy the workflow: name contains an absolute local path ("${token}"). `
|
|
242
|
+
+ 'It is rendered by hosted browser JS and must never leave this machine; name files '
|
|
243
|
+
+ 'repo-relative. Nothing was written.');
|
|
244
|
+
}
|
|
245
|
+
const { data, error } = await client.rpc('cliv2_duplicate_workflow', {
|
|
246
|
+
p_workflow_id: input.workflowId,
|
|
247
|
+
p_name: input.name,
|
|
248
|
+
p_from_agent: input.fromAgent,
|
|
249
|
+
});
|
|
250
|
+
if (error)
|
|
251
|
+
throw new Error(`could not copy the workflow: ${error.message}`);
|
|
252
|
+
if (typeof data !== 'string' || data === '') {
|
|
253
|
+
throw new Error('the copy may have been made, but its id did not come back. Look for it on /workflows.');
|
|
254
|
+
}
|
|
255
|
+
return data;
|
|
256
|
+
}
|
package/package.json
CHANGED