@ctrl-spc/cs 0.7.3 → 0.7.5
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-lock.js +48 -0
- package/dist/daemon.js +114 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/mcp.js +90 -339
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +57 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +967 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/steps.js +168 -0
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
package/dist/steps.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { absolutePathToken } from './local-paths.js';
|
|
2
|
+
export const STEP_STATUSES = ['pending', 'in_progress', 'blocked', 'interrupted', 'done'];
|
|
3
|
+
const STAGE_COLUMNS = 'id, work_item_id, title, position, source_kind, source_label, source_ref, source_workflow_ref';
|
|
4
|
+
const STEP_COLUMNS = 'id, stage_id, title, charter, next_action, status, position, source_kind, source_label, source_ref';
|
|
5
|
+
async function read(query, subject) {
|
|
6
|
+
const { data, error } = await query;
|
|
7
|
+
if (error)
|
|
8
|
+
throw new Error(`could not read ${subject}: ${error.message}`);
|
|
9
|
+
return data ?? [];
|
|
10
|
+
}
|
|
11
|
+
const stageOf = (row) => ({
|
|
12
|
+
id: row.id,
|
|
13
|
+
workItemId: row.work_item_id,
|
|
14
|
+
title: row.title,
|
|
15
|
+
position: row.position,
|
|
16
|
+
sourceKind: row.source_kind,
|
|
17
|
+
sourceLabel: row.source_label,
|
|
18
|
+
sourceRef: row.source_ref,
|
|
19
|
+
sourceWorkflowRef: row.source_workflow_ref,
|
|
20
|
+
});
|
|
21
|
+
const stepOf = (row) => ({
|
|
22
|
+
id: row.id,
|
|
23
|
+
stageId: row.stage_id,
|
|
24
|
+
title: row.title,
|
|
25
|
+
charter: row.charter,
|
|
26
|
+
nextAction: row.next_action,
|
|
27
|
+
status: row.status,
|
|
28
|
+
position: row.position,
|
|
29
|
+
sourceKind: row.source_kind,
|
|
30
|
+
sourceLabel: row.source_label,
|
|
31
|
+
sourceRef: row.source_ref,
|
|
32
|
+
});
|
|
33
|
+
/** Refuse prose that carries an absolute local path, before it is written. */
|
|
34
|
+
function sweep(action, prose) {
|
|
35
|
+
for (const [field, value] of prose) {
|
|
36
|
+
const token = value === undefined ? null : absolutePathToken(value);
|
|
37
|
+
if (token) {
|
|
38
|
+
throw new Error(`could not ${action}: ${field} contains an absolute local path ("${token}"). It is rendered `
|
|
39
|
+
+ 'by hosted browser JS and must never leave this machine; name files repo-relative. '
|
|
40
|
+
+ 'Nothing was written.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The stages on one work item, in the order they are worked. */
|
|
45
|
+
export async function stagesOnItem(client, workItemId) {
|
|
46
|
+
const rows = await read(client.from('cliv2_stages').select(STAGE_COLUMNS).eq('work_item_id', workItemId)
|
|
47
|
+
.order('position', { ascending: true }).order('created_at', { ascending: true }), `the stages of work item ${workItemId}`);
|
|
48
|
+
return rows.map(stageOf);
|
|
49
|
+
}
|
|
50
|
+
/** One stage by id, or null when there is no such row or it is not the caller's. */
|
|
51
|
+
export async function stageById(client, stageId) {
|
|
52
|
+
const rows = await read(client.from('cliv2_stages').select(STAGE_COLUMNS).eq('id', stageId), `stage ${stageId}`);
|
|
53
|
+
return rows.length === 0 ? null : stageOf(rows[0]);
|
|
54
|
+
}
|
|
55
|
+
/** One step by id, or null when there is no such row or it is not the caller's. */
|
|
56
|
+
export async function stepById(client, stepId) {
|
|
57
|
+
const rows = await read(client.from('cliv2_steps').select(STEP_COLUMNS).eq('id', stepId), `step ${stepId}`);
|
|
58
|
+
return rows.length === 0 ? null : stepOf(rows[0]);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Put a workflow's stages on a work item, once.
|
|
62
|
+
*
|
|
63
|
+
* `cliv2_start_workflow` is the one guard: it refuses a workflow already on
|
|
64
|
+
* the item, atomically, with "already started". That refusal is turned into a
|
|
65
|
+
* calm `{ started: false }` here, because an owner restarted at a stage
|
|
66
|
+
* boundary calls this again and must not read its own earlier success as a
|
|
67
|
+
* failure. Any other refusal comes back verbatim.
|
|
68
|
+
*/
|
|
69
|
+
export async function startWorkflowOnItem(client, workItemId, workflowId) {
|
|
70
|
+
const { data, error } = await client.rpc('cliv2_start_workflow', {
|
|
71
|
+
p_work_item_id: workItemId,
|
|
72
|
+
p_workflow_id: workflowId,
|
|
73
|
+
});
|
|
74
|
+
if (error) {
|
|
75
|
+
if (/already started/i.test(error.message)) {
|
|
76
|
+
const existing = (await stagesOnItem(client, workItemId))
|
|
77
|
+
.filter((stage) => stage.sourceWorkflowRef === workflowId);
|
|
78
|
+
return { started: false, stages: existing.length };
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`could not start workflow ${workflowId} on work item ${workItemId}: ${error.message}. `
|
|
81
|
+
+ 'Nothing was written.');
|
|
82
|
+
}
|
|
83
|
+
return { started: true, stages: Number(data) };
|
|
84
|
+
}
|
|
85
|
+
/** The whole spine of one work item plus the one shared "up next". */
|
|
86
|
+
export async function listSteps(client, workItemId) {
|
|
87
|
+
const stages = await stagesOnItem(client, workItemId);
|
|
88
|
+
const steps = stages.length === 0 ? [] : await read(client.from('cliv2_steps').select(STEP_COLUMNS).in('stage_id', stages.map((stage) => stage.id))
|
|
89
|
+
.order('position', { ascending: true }).order('created_at', { ascending: true }), `the steps of work item ${workItemId}`);
|
|
90
|
+
const { data: upNext, error } = await client.rpc('cliv2_steps_up_next', { p_work_item_id: workItemId });
|
|
91
|
+
if (error)
|
|
92
|
+
throw new Error(`could not read what is up next on work item ${workItemId}: ${error.message}`);
|
|
93
|
+
return {
|
|
94
|
+
stages: stages.map((stage) => ({
|
|
95
|
+
...stage,
|
|
96
|
+
steps: steps.filter((row) => row.stage_id === stage.id).map(stepOf),
|
|
97
|
+
})),
|
|
98
|
+
upNext,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Record one step inside a stage. Provenance is COPIED FROM THE STAGE, never
|
|
103
|
+
* chosen by the caller: a step under a workflow stage says which workflow, and
|
|
104
|
+
* a step under a plan stage says which plan, and an agent cannot mislabel one.
|
|
105
|
+
*/
|
|
106
|
+
export async function createStep(client, stage, input) {
|
|
107
|
+
const action = `record the step "${input.title}"`;
|
|
108
|
+
for (const [field, value] of [['title', input.title], ['charter', input.charter], ['next_action', input.nextAction]]) {
|
|
109
|
+
if (value.trim() === '')
|
|
110
|
+
throw new Error(`could not ${action}: ${field} is blank. Nothing was written.`);
|
|
111
|
+
}
|
|
112
|
+
if (!Number.isInteger(input.position)) {
|
|
113
|
+
throw new Error(`could not ${action}: position must be a whole number. Nothing was written.`);
|
|
114
|
+
}
|
|
115
|
+
sweep(action, [['title', input.title], ['charter', input.charter], ['next_action', input.nextAction]]);
|
|
116
|
+
const rows = await read(client.from('cliv2_steps').insert({
|
|
117
|
+
stage_id: stage.id,
|
|
118
|
+
title: input.title,
|
|
119
|
+
charter: input.charter,
|
|
120
|
+
next_action: input.nextAction,
|
|
121
|
+
position: input.position,
|
|
122
|
+
source_kind: stage.sourceKind,
|
|
123
|
+
source_label: stage.sourceLabel,
|
|
124
|
+
source_ref: stage.sourceRef,
|
|
125
|
+
}).select('id'), `the step just written under stage ${stage.id}`);
|
|
126
|
+
if (rows.length === 0)
|
|
127
|
+
throw new Error(`could not ${action}: the write returned no row.`);
|
|
128
|
+
return { id: rows[0].id };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Change a step's living fields. The database grants update on exactly these
|
|
132
|
+
* four columns, so a stage, position or provenance change is refused there
|
|
133
|
+
* whatever a caller sends; this refuses the two things the database cannot see:
|
|
134
|
+
* a call that changes nothing, and a blank string offered as a way to clear.
|
|
135
|
+
*/
|
|
136
|
+
export async function updateStep(client, step, changes) {
|
|
137
|
+
const action = `update the step "${step.title}"`;
|
|
138
|
+
const patch = {};
|
|
139
|
+
const updated = [];
|
|
140
|
+
const consider = (field, next, current) => {
|
|
141
|
+
if (next === undefined)
|
|
142
|
+
return;
|
|
143
|
+
if (next.trim() === '') {
|
|
144
|
+
throw new Error(`could not ${action}: ${field} is blank. A field cannot be cleared; nothing was written.`);
|
|
145
|
+
}
|
|
146
|
+
if (next === current)
|
|
147
|
+
return;
|
|
148
|
+
patch[field] = next;
|
|
149
|
+
updated.push(field);
|
|
150
|
+
};
|
|
151
|
+
if (changes.status !== undefined && !STEP_STATUSES.includes(changes.status)) {
|
|
152
|
+
throw new Error(`could not ${action}: "${changes.status}" is not a status. Use one of ${STEP_STATUSES.join(', ')}. `
|
|
153
|
+
+ 'Nothing was written.');
|
|
154
|
+
}
|
|
155
|
+
consider('title', changes.title, step.title);
|
|
156
|
+
consider('charter', changes.charter, step.charter);
|
|
157
|
+
consider('next_action', changes.nextAction, step.nextAction);
|
|
158
|
+
consider('status', changes.status, step.status);
|
|
159
|
+
if (updated.length === 0) {
|
|
160
|
+
throw new Error(`could not ${action}: nothing would change. Nothing was written.`);
|
|
161
|
+
}
|
|
162
|
+
sweep(action, [['title', changes.title], ['charter', changes.charter], ['next_action', changes.nextAction]]);
|
|
163
|
+
const rows = await read(client.from('cliv2_steps').update(patch).eq('id', step.id).select('id'), `the step just updated (${step.id})`);
|
|
164
|
+
if (rows.length === 0) {
|
|
165
|
+
throw new Error(`could not ${action}: the update matched no row, so nothing was written.`);
|
|
166
|
+
}
|
|
167
|
+
return { id: step.id, updated: updated.sort() };
|
|
168
|
+
}
|
package/dist/supabase.js
CHANGED
|
@@ -58,11 +58,38 @@ const retryingFetch = async (input, init) => {
|
|
|
58
58
|
* lifetime: `autoRefreshToken: true` (v1 had it false with no refresh loop, so
|
|
59
59
|
* after an hour every write 401'd silently) and the retrying fetch above.
|
|
60
60
|
* Rotated tokens are written straight back to disk via onAuthStateChange.
|
|
61
|
+
*
|
|
62
|
+
* Pass `{ refreshing: false }` for a client that can never rotate the stored
|
|
63
|
+
* refresh token, for a caller that shares session.json with another process:
|
|
64
|
+
* two processes refreshing the same token family revoke it for both.
|
|
61
65
|
*/
|
|
62
|
-
export async function getClient() {
|
|
66
|
+
export async function getClient({ refreshing = true } = {}) {
|
|
63
67
|
const stored = readSession();
|
|
64
|
-
|
|
68
|
+
// The refreshing path still requires both, exactly as today. The
|
|
69
|
+
// non-refreshing one is never handed a refresh token, so it must not demand
|
|
70
|
+
// one: requiring it would refuse a session this client can legitimately use.
|
|
71
|
+
if (!stored?.access_token)
|
|
72
|
+
throw new NotLoggedIn();
|
|
73
|
+
if (refreshing && !stored?.refresh_token)
|
|
65
74
|
throw new NotLoggedIn();
|
|
75
|
+
/* ═══ NOT REFRESHING MEANS NEVER HANDED THE REFRESH TOKEN. ═══ Not merely
|
|
76
|
+
`autoRefreshToken: false`: auth-js refreshes inside `setSession` whenever
|
|
77
|
+
the access token has already expired, regardless of that flag
|
|
78
|
+
(`GoTrueClient.js:2994`). A client never given the refresh token has
|
|
79
|
+
nothing to rotate, which is the only guarantee that holds. Two processes
|
|
80
|
+
rotating one `session.json` revoke the family for both.
|
|
81
|
+
|
|
82
|
+
An already-expired token is refused here rather than left to fail at the
|
|
83
|
+
first query, so callers keep the `NotLoggedIn` boundary they have today.
|
|
84
|
+
Same pattern as `scripts/prove-scope-history.mjs:34-41`. */
|
|
85
|
+
if (!refreshing) {
|
|
86
|
+
if (accessTokenExpired(stored.access_token))
|
|
87
|
+
throw new NotLoggedIn();
|
|
88
|
+
return createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
89
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
90
|
+
global: { fetch: retryingFetch, headers: { Authorization: `Bearer ${stored.access_token}` } },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
66
93
|
const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
67
94
|
auth: { persistSession: false, autoRefreshToken: true },
|
|
68
95
|
global: { fetch: retryingFetch },
|
|
@@ -84,3 +111,17 @@ export async function getClient() {
|
|
|
84
111
|
}
|
|
85
112
|
return client;
|
|
86
113
|
}
|
|
114
|
+
/** Whether a stored access token's own `exp` claim has already passed. A token
|
|
115
|
+
* this process cannot parse is treated as unusable rather than trusted: the
|
|
116
|
+
* caller's next request would 401 anyway, and `NotLoggedIn` is the honest
|
|
117
|
+
* answer at the boundary rather than a raw SyntaxError from the middle of a
|
|
118
|
+
* request handler. Exported so a test can pass a string and nothing else. */
|
|
119
|
+
export function accessTokenExpired(token) {
|
|
120
|
+
try {
|
|
121
|
+
const claims = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString());
|
|
122
|
+
return typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
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