@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/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, and the exits off them.
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
- * FOUR READS RATHER THAN ONE EMBED. `cliv2_workflow_stages_in_workflow` carries
15
- * two foreign keys into `cliv2_workflow_stages` (`stage_id` and
16
- * `loop_to_stage_id`), so a nested embed of the stage document would need
17
- * disambiguating by constraint name a coupling to a constraint's spelling for
18
- * the sake of saving a round trip on a tool call an agent makes once.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"