@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/panel3/tools.js
CHANGED
|
@@ -118,10 +118,26 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
118
118
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
119
119
|
import { z } from 'zod';
|
|
120
120
|
import { returned } from './client.js';
|
|
121
|
+
import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
|
|
121
122
|
import { rememberSecret, redactArgs } from './secrets.js';
|
|
122
123
|
import { workBrief } from './prompt.js';
|
|
124
|
+
/* The harness this daemon spawns with, which is the process this server runs
|
|
125
|
+
in. It is what `panel3_runs.harness` is written from at spawn, so reading it
|
|
126
|
+
here is the same fact without a second round trip. */
|
|
127
|
+
import { harness } from './spawn.js';
|
|
123
128
|
import { listCodebases } from '../codebases.js';
|
|
124
|
-
import { readWorkflow } from '../workflows.js';
|
|
129
|
+
import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage } from '../workflows.js';
|
|
130
|
+
/* ═══ A THIRD GENERATION-NEUTRAL MODULE, AND IT IS A DECISION. ═══
|
|
131
|
+
conventions.md enumerates what `panel3/` may reach outside itself and says
|
|
132
|
+
"and nothing else", so this line amends that enumeration rather than slipping
|
|
133
|
+
past it, and `panel3-isolation.contract.test.mjs` fails until the list agrees.
|
|
134
|
+
`screenshots.ts` names no `cliv2_` table and no v1 or v2 concept: it is a PNG
|
|
135
|
+
parser and a byte bound. The alternative was copying 215 lines of chunk
|
|
136
|
+
walking into `panel3/` to avoid one entry in a list, which is the duplication
|
|
137
|
+
the guide forbids, and it would have left two answers to "is this a PNG". */
|
|
138
|
+
import { readPngScreenshot, screenshotArtifactId, MAX_SCREENSHOT_BYTES } from '../screenshots.js';
|
|
139
|
+
import { lstat } from 'node:fs/promises';
|
|
140
|
+
import { isAbsolute } from 'node:path';
|
|
125
141
|
import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, setMainBranch, withAskContent, } from './show.js';
|
|
126
142
|
// ---------------------------------------------------------------------------
|
|
127
143
|
// READING AND WRITING. Every query goes through the same guard every other v3
|
|
@@ -218,6 +234,24 @@ const secretScope = ({ runId, processToken }) => processToken === undefined ? ru
|
|
|
218
234
|
* child text is ever inherited by what the person reads.
|
|
219
235
|
*/
|
|
220
236
|
const PERSON_READS_LIMIT = 700;
|
|
237
|
+
/**
|
|
238
|
+
* ═══ HOW MANY WORDS MAY NAME THE WORK ON A CARD, AND WHY IT IS SIX. ═══
|
|
239
|
+
*
|
|
240
|
+
* The name is read on one line three inches wide, and the branch for the work
|
|
241
|
+
* is cut from the same words, so both want a name rather than a sentence. Six
|
|
242
|
+
* is room for one: "Fix the sign-out checklist bug" is five.
|
|
243
|
+
*
|
|
244
|
+
* ═══ AND IT REFUSES RATHER THAN CUTTING THE NAME DOWN. ═══ `say.ts` already
|
|
245
|
+
* owns the one rule for turning a long line into a title. A second cut here
|
|
246
|
+
* would be a second answer to that question inside one package, and it would be
|
|
247
|
+
* the answer nobody saw being given: half a name is written, the card carries
|
|
248
|
+
* it, and the branch is cut from it. Refusing puts it back to the one caller
|
|
249
|
+
* that can still say it shorter, and it comes back through `buildServer`'s
|
|
250
|
+
* catch as a correctable failure rather than a protocol error, which is the
|
|
251
|
+
* same reason `PERSON_READS_LIMIT` above is checked in a handler and not on the
|
|
252
|
+
* schema.
|
|
253
|
+
*/
|
|
254
|
+
const WORK_NAME_WORDS = 6;
|
|
221
255
|
async function asked(caller, args, askPerson) {
|
|
222
256
|
const { client, runId, processToken } = caller;
|
|
223
257
|
const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
|
|
@@ -434,6 +468,14 @@ function elapsed(startedAt, endedAt, resumedAt) {
|
|
|
434
468
|
* enforces it. Both copies are reading the same authority: the bucket the
|
|
435
469
|
* migration created (20260722180000_skills.sql). */
|
|
436
470
|
const SKILL_BUNDLES_BUCKET = 'skill-bundles';
|
|
471
|
+
/** Owner-scoped and private, the one `20260924090000_panel3_images.sql` created.
|
|
472
|
+
* Never the org-scoped `artifacts` bucket: a `panel3_images` row is readable by
|
|
473
|
+
* its owner alone, and a row that private pointing at a PNG the whole org can
|
|
474
|
+
* read would make the privacy a claim rather than a fact. */
|
|
475
|
+
/* Exported because the daemon downloads a card's pictures into the run's cwd
|
|
476
|
+
before it builds the prompt (33 Slice 6), and a second literal is a second
|
|
477
|
+
place for the bucket to be renamed wrong. */
|
|
478
|
+
export const PANEL3_IMAGES_BUCKET = 'panel3-images';
|
|
437
479
|
/**
|
|
438
480
|
* ═══ A REMOVED SKILL IS NOT A MISSING ONE, AND AN AGENT TOLD OTHERWISE SAYS
|
|
439
481
|
* THE WRONG THING TO THE PERSON. ═══
|
|
@@ -524,6 +566,47 @@ async function skillDocument(client, storagePath, what) {
|
|
|
524
566
|
}
|
|
525
567
|
return await data.text();
|
|
526
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* ═══ WHY A FILE COULD NOT BE SHOWN, SAID WITHOUT NAMING IT. ═══
|
|
571
|
+
*
|
|
572
|
+
* `attach_image` is handed an absolute local path, and every failure below it
|
|
573
|
+
* carries that path in its message: `readPngScreenshot` interpolates it four
|
|
574
|
+
* times, and an `ENOENT` from Node restates it again in its own wording. A
|
|
575
|
+
* tool's thrown message is what the AGENT reads, and an agent that has just
|
|
576
|
+
* failed quotes its own error into a `say`, which lands in `panel3_turns.body` —
|
|
577
|
+
* a cloud column a browser renders. AGENTS.md's rule is that no absolute path
|
|
578
|
+
* reaches the cloud, so the leak has to be closed here, two hops before it.
|
|
579
|
+
*
|
|
580
|
+
* NOT BY EDITING THE MESSAGE FROM BELOW. Stripping the path out of a string that
|
|
581
|
+
* already contains it is a guess about wording that holds until some layer
|
|
582
|
+
* phrases it differently — and the layer most likely to is the operating system,
|
|
583
|
+
* which owes this codebase nothing. So the reason is re-derived here and the text
|
|
584
|
+
* from below is discarded entirely.
|
|
585
|
+
*
|
|
586
|
+
* The agent still gets an actionable sentence, which is the whole point of
|
|
587
|
+
* telling it anything: the four cases it can do something about are separated,
|
|
588
|
+
* and everything else says the one true thing left.
|
|
589
|
+
*/
|
|
590
|
+
async function whyNotAPicture(localPath) {
|
|
591
|
+
if (!isAbsolute(localPath)) {
|
|
592
|
+
return 'that is not an absolute path, and this needs one.';
|
|
593
|
+
}
|
|
594
|
+
let stat;
|
|
595
|
+
try {
|
|
596
|
+
stat = await lstat(localPath);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return 'there is no file at that path on this machine.';
|
|
600
|
+
}
|
|
601
|
+
if (!stat.isFile())
|
|
602
|
+
return 'that path is a folder or a link, not a file.';
|
|
603
|
+
if (stat.size === 0)
|
|
604
|
+
return 'that file is empty.';
|
|
605
|
+
if (stat.size > MAX_SCREENSHOT_BYTES) {
|
|
606
|
+
return `that file is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit.`;
|
|
607
|
+
}
|
|
608
|
+
return 'that file could not be read as a PNG. It must be a real, uncompressed-format PNG.';
|
|
609
|
+
}
|
|
527
610
|
/**
|
|
528
611
|
* ═══ THE PATH A SKILL NAMED IS NOT TRUSTED. ═══
|
|
529
612
|
*
|
|
@@ -585,6 +668,37 @@ const CREDENTIAL_INSTRUCTION = 'This is a SECRET, and it is yours to USE. Make t
|
|
|
585
668
|
+ 'itself into your report, your answer, a progress line, a question, a comment, an artifact or a '
|
|
586
669
|
+ 'file you commit, because all of those are shared and permanent. Name the credential you used '
|
|
587
670
|
+ 'instead. Never refuse the work to avoid touching the value.';
|
|
671
|
+
/**
|
|
672
|
+
* The exit rules whose refusal carries a NUMBER, checked in the 1-based
|
|
673
|
+
* numbering the agent sent and returned in the 0-based one the RPC takes.
|
|
674
|
+
*
|
|
675
|
+
* SHARED BY `create_workflow` AND `edit_workflow`, because they are the same
|
|
676
|
+
* rules over the same list: `cliv2_build_workflow` and `cliv2_edit_workflow`
|
|
677
|
+
* both count stages from 0, a numbering neither tool ever showed the agent, so
|
|
678
|
+
* both refuse here in the numbering the agent used.
|
|
679
|
+
*/
|
|
680
|
+
function zeroBasedExits(exits, stageCount) {
|
|
681
|
+
for (const exit of exits) {
|
|
682
|
+
const where = `exit from stage ${exit.from_stage} to stage ${exit.to_stage}`;
|
|
683
|
+
const inRange = (n) => n >= 1 && n <= stageCount;
|
|
684
|
+
if (!inRange(exit.from_stage) || !inRange(exit.to_stage)) {
|
|
685
|
+
throw new Error(`${where} names a stage this workflow does not have (stages are 1..${stageCount}). `
|
|
686
|
+
+ 'Nothing was written.');
|
|
687
|
+
}
|
|
688
|
+
if (exit.to_stage > exit.from_stage) {
|
|
689
|
+
throw new Error(`${where} jumps forward. to_stage must be earlier than from_stage, or the same stage. `
|
|
690
|
+
+ 'Nothing was written.');
|
|
691
|
+
}
|
|
692
|
+
if (exit.condition.trim() === '') {
|
|
693
|
+
throw new Error(`the exit from stage ${exit.from_stage} needs a condition. Nothing was written.`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return exits.map((exit) => ({
|
|
697
|
+
from: exit.from_stage - 1,
|
|
698
|
+
to: exit.to_stage - 1,
|
|
699
|
+
condition: exit.condition.trim(),
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
588
702
|
// ---------------------------------------------------------------------------
|
|
589
703
|
// THE TOOLS.
|
|
590
704
|
//
|
|
@@ -837,6 +951,308 @@ const TOOLS = [
|
|
|
837
951
|
].join('\n');
|
|
838
952
|
},
|
|
839
953
|
},
|
|
954
|
+
{
|
|
955
|
+
name: 'create_workflow',
|
|
956
|
+
/* ═══ LEVEL 2 ALONE, FOR THE REASON `get_workflow` IS. ═══ The owner is the
|
|
957
|
+
agent talking to the person, so it is the one that hears a process
|
|
958
|
+
described and can write it down as it was described. A launcher writes one
|
|
959
|
+
line and exits; a worker owns one piece of somebody else's process. */
|
|
960
|
+
levels: [2],
|
|
961
|
+
description: "Write a new workflow into this organisation's library: a name, what it is about, and its "
|
|
962
|
+
+ 'stages in order, each with a one-line description and the markdown document an agent '
|
|
963
|
+
+ 'following it reads. Stages are numbered from 1, the way get_workflow prints them. exits '
|
|
964
|
+
+ 'are the conditional ways back: from_stage N, if condition, go back to to_stage M, where M '
|
|
965
|
+
+ 'is earlier than N or the same stage; a forward jump is refused and nothing is written. '
|
|
966
|
+
+ 'Takes no organisation or project: the workflow goes into the organisation this '
|
|
967
|
+
+ "conversation's project belongs to. It appears on /workflows at once. Never put an "
|
|
968
|
+
+ 'absolute local path in any field.',
|
|
969
|
+
input: {
|
|
970
|
+
name: z.string(),
|
|
971
|
+
description: z.string().optional(),
|
|
972
|
+
stages: z.array(z.object({
|
|
973
|
+
name: z.string(),
|
|
974
|
+
description: z.string().optional(),
|
|
975
|
+
body: z.string().describe('the whole stage document, in markdown'),
|
|
976
|
+
})),
|
|
977
|
+
exits: z.array(z.object({
|
|
978
|
+
from_stage: z.number().int(),
|
|
979
|
+
to_stage: z.number().int(),
|
|
980
|
+
condition: z.string(),
|
|
981
|
+
})).optional(),
|
|
982
|
+
},
|
|
983
|
+
handler: async (caller, args) => {
|
|
984
|
+
const { name, description, stages, exits } = args;
|
|
985
|
+
/* ═══ CHECKED HERE IN THE NUMBERING THE AGENT SENT. ═══
|
|
986
|
+
`cliv2_build_workflow` enforces every one of these itself, and its
|
|
987
|
+
messages count stages from 0, a numbering this tool never showed the
|
|
988
|
+
agent, which would have it correcting the wrong stage. So the rules
|
|
989
|
+
whose refusal carries a NUMBER are pre-checked and worded in 1-based
|
|
990
|
+
terms; everything else (the grant, "at least one stage", anything
|
|
991
|
+
unforeseen) is left to the RPC and passed back as it came. */
|
|
992
|
+
const workflowName = name.trim();
|
|
993
|
+
if (workflowName === '')
|
|
994
|
+
throw new Error('a workflow needs a name. Nothing was written.');
|
|
995
|
+
/* THE RPC REFUSES THIS TOO, and its message carries no number, so it
|
|
996
|
+
would ordinarily be left to it. It is checked here because the exit
|
|
997
|
+
range below would otherwise read "stages are 1..0", which is not a
|
|
998
|
+
range and is not the thing that is actually wrong. */
|
|
999
|
+
if (stages.length === 0) {
|
|
1000
|
+
throw new Error('a workflow needs at least one stage. Nothing was written.');
|
|
1001
|
+
}
|
|
1002
|
+
stages.forEach((stage, i) => {
|
|
1003
|
+
if (stage.name.trim() === '')
|
|
1004
|
+
throw new Error(`stage ${i + 1} needs a name. Nothing was written.`);
|
|
1005
|
+
});
|
|
1006
|
+
const wanted = zeroBasedExits(exits ?? [], stages.length);
|
|
1007
|
+
/* ═══ THE ORGANISATION IS THE CONVERSATION'S, NOT THE AGENT'S. ═══ There
|
|
1008
|
+
is no org argument to get wrong, and a record-only card genuinely has
|
|
1009
|
+
none, which is said rather than guessed at. */
|
|
1010
|
+
const projectId = await projectOfCard(caller);
|
|
1011
|
+
if (projectId === null) {
|
|
1012
|
+
throw new Error('this conversation has no project, so there is no organisation to write the workflow into.');
|
|
1013
|
+
}
|
|
1014
|
+
const project = await only(caller.client.from('projects').select('id, org_id').eq('id', projectId).is('archived_at', null), 'read', `the project this conversation is in (${projectId})`);
|
|
1015
|
+
const id = await buildWorkflow(caller.client, {
|
|
1016
|
+
orgId: project.org_id,
|
|
1017
|
+
name: workflowName,
|
|
1018
|
+
description: description?.trim() ?? '',
|
|
1019
|
+
stages: stages.map((stage) => ({
|
|
1020
|
+
name: stage.name.trim(),
|
|
1021
|
+
description: stage.description?.trim() ?? '',
|
|
1022
|
+
/* THE BODY IS NOT TRIMMED. It is a markdown document, and its leading
|
|
1023
|
+
blank lines are the author's own. */
|
|
1024
|
+
body: stage.body,
|
|
1025
|
+
})),
|
|
1026
|
+
exits: wanted,
|
|
1027
|
+
fromAgent: harness(),
|
|
1028
|
+
});
|
|
1029
|
+
await receipt(caller, 'workflow', id, workflowName);
|
|
1030
|
+
return `Created workflow ${workflowName}, id ${id}, with ${stages.length} stages. `
|
|
1031
|
+
+ 'It is now in the library.';
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
name: 'reword_stage',
|
|
1036
|
+
/* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
|
|
1037
|
+
agent hearing the person say how a stage should read. */
|
|
1038
|
+
levels: [2],
|
|
1039
|
+
description: 'Reword one stage of the workflow attached to this conversation: its name, its one-line '
|
|
1040
|
+
+ 'description, or its whole markdown document. Name the stage by the number get_workflow '
|
|
1041
|
+
+ 'prints. Send only the fields to change; a field left out is left as it is. A stage is one '
|
|
1042
|
+
+ 'shared library row, so if other workflows hold it they change too, and the reply names them. '
|
|
1043
|
+
+ 'Never put an absolute local path in any field.',
|
|
1044
|
+
input: {
|
|
1045
|
+
workflow_id: z.string(),
|
|
1046
|
+
stage: z.number().int().describe('the stage number, counted from 1 as get_workflow prints it'),
|
|
1047
|
+
name: z.string().optional(),
|
|
1048
|
+
description: z.string().optional(),
|
|
1049
|
+
body: z.string().optional().describe('the whole stage document, in markdown; replaces the current one'),
|
|
1050
|
+
},
|
|
1051
|
+
handler: async (caller, args) => {
|
|
1052
|
+
const { workflow_id, stage, name, description, body } = args;
|
|
1053
|
+
if (name === undefined && description === undefined && body === undefined) {
|
|
1054
|
+
throw new Error('send at least one of name, description or body. Nothing was written.');
|
|
1055
|
+
}
|
|
1056
|
+
const stageName = name?.trim();
|
|
1057
|
+
if (stageName === '')
|
|
1058
|
+
throw new Error('a stage needs a name. Nothing was written.');
|
|
1059
|
+
const workflow = await readWorkflow(caller.client, workflow_id);
|
|
1060
|
+
if (workflow.archivedAt !== null) {
|
|
1061
|
+
throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
|
|
1062
|
+
}
|
|
1063
|
+
/* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT.
|
|
1064
|
+
═══ No v3 tool at any level takes a stage uuid, so the numbering is
|
|
1065
|
+
`get_workflow`'s presentation and the mapping stays here. `stage: 0`
|
|
1066
|
+
indexes -1, is undefined, and is refused with the range. */
|
|
1067
|
+
const target = workflow.stages[stage - 1];
|
|
1068
|
+
if (!target) {
|
|
1069
|
+
throw new Error(`stage ${stage} is not a stage of ${workflow.name} (stages are 1..${workflow.stages.length}). `
|
|
1070
|
+
+ 'Nothing was written.');
|
|
1071
|
+
}
|
|
1072
|
+
const { alsoIn } = await rewordStage(caller.client, {
|
|
1073
|
+
workflowId: workflow.id,
|
|
1074
|
+
stageId: target.id,
|
|
1075
|
+
name: stageName,
|
|
1076
|
+
/* A WHITESPACE-ONLY DESCRIPTION CLEARS IT. An empty description is a
|
|
1077
|
+
real state (the column defaults to one); an empty name is not. */
|
|
1078
|
+
description: description?.trim(),
|
|
1079
|
+
/* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
|
|
1080
|
+
body,
|
|
1081
|
+
});
|
|
1082
|
+
/* THE WORKFLOW IS WHAT THE PERSON OPENS, because no v3 surface shows a
|
|
1083
|
+
stage on its own. */
|
|
1084
|
+
await receiptOnce(caller, 'workflow', workflow.id, workflow.name);
|
|
1085
|
+
const changed = [
|
|
1086
|
+
name !== undefined && 'name',
|
|
1087
|
+
description !== undefined && 'description',
|
|
1088
|
+
body !== undefined && 'body',
|
|
1089
|
+
].filter(Boolean).join(', ');
|
|
1090
|
+
return [
|
|
1091
|
+
`Reworded stage ${stage} ${stageName ?? target.name} of ${workflow.name} (${changed}).`,
|
|
1092
|
+
alsoIn.length === 0
|
|
1093
|
+
? 'It is in no other live workflow.'
|
|
1094
|
+
: `That stage is shared, so these workflows changed too: ${alsoIn.join(', ')}.`,
|
|
1095
|
+
/* ═══ A RENAME DOES NOT REACH A PLAN ALREADY RUNNING. ═══
|
|
1096
|
+
`cliv2_start_workflow` copies the stage NAME onto the work item's plan
|
|
1097
|
+
and keeps only a reference for the document, so a body change flows to
|
|
1098
|
+
a run in flight and a name change does not. Said when the name
|
|
1099
|
+
actually changed; naming the runs is not done, because the read that
|
|
1100
|
+
exists, `cliv2_workflow_runs`, names every run of a workflow rather
|
|
1101
|
+
than the runs carrying this stage. */
|
|
1102
|
+
...(stageName === undefined || stageName === target.name ? [] : [
|
|
1103
|
+
'Work items already running a workflow that holds it keep the old stage name on their plan.',
|
|
1104
|
+
]),
|
|
1105
|
+
].join(' ');
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
name: 'edit_workflow',
|
|
1110
|
+
/* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
|
|
1111
|
+
agent hearing the person say the process has changed shape. */
|
|
1112
|
+
levels: [2],
|
|
1113
|
+
description: 'Change the shape of the workflow attached to this conversation: its name, what it is about, '
|
|
1114
|
+
+ 'which stages it has and in what order, and the conditional ways back. stages is the WHOLE '
|
|
1115
|
+
+ 'new list, in order: a stage left out is removed from the workflow (it stays in the library). '
|
|
1116
|
+
+ 'Each entry is either { keep: N } to keep stage N as get_workflow prints it, or { name, '
|
|
1117
|
+
+ 'description, body } to write a new stage. exits is the WHOLE new list of conditional ways '
|
|
1118
|
+
+ 'back too, and may be empty: from_stage and to_stage count from 1 in the NEW list, to_stage '
|
|
1119
|
+
+ 'must be earlier than from_stage or the same, and exits off one stage are tried in the order '
|
|
1120
|
+
+ 'sent. name and description left out are left as they are; an empty description clears it. '
|
|
1121
|
+
+ 'To change the wording of an '
|
|
1122
|
+
+ 'existing stage use reword_stage. Refused, with nothing written, while any work item is '
|
|
1123
|
+
+ 'running the workflow. Never put an absolute local path in any field.',
|
|
1124
|
+
input: {
|
|
1125
|
+
workflow_id: z.string(),
|
|
1126
|
+
name: z.string().optional(),
|
|
1127
|
+
description: z.string().optional(),
|
|
1128
|
+
stages: z.array(z.union([
|
|
1129
|
+
z.object({ keep: z.number().int().describe('the stage number as get_workflow prints it') }).strict(),
|
|
1130
|
+
z.object({
|
|
1131
|
+
name: z.string(),
|
|
1132
|
+
description: z.string().optional(),
|
|
1133
|
+
body: z.string().describe('the whole stage document, in markdown'),
|
|
1134
|
+
}).strict(),
|
|
1135
|
+
])),
|
|
1136
|
+
exits: z.array(z.object({
|
|
1137
|
+
from_stage: z.number().int(),
|
|
1138
|
+
to_stage: z.number().int(),
|
|
1139
|
+
condition: z.string(),
|
|
1140
|
+
})).optional().describe('the whole new list of conditional ways back; send [] for none'),
|
|
1141
|
+
},
|
|
1142
|
+
handler: async (caller, args) => {
|
|
1143
|
+
const { workflow_id, name, description, stages, exits } = args;
|
|
1144
|
+
const workflowName = name?.trim();
|
|
1145
|
+
if (workflowName === '')
|
|
1146
|
+
throw new Error('a workflow needs a name. Nothing was written.');
|
|
1147
|
+
if (stages.length === 0) {
|
|
1148
|
+
throw new Error('a workflow needs at least one stage, and stages is its WHOLE new list. Nothing was written.');
|
|
1149
|
+
}
|
|
1150
|
+
/* ═══ AN ABSENT `exits` IS REFUSED IN WORDS, NEVER DEFAULTED TO `[]`. ═══
|
|
1151
|
+
The RPC REPLACES the exits from what it is sent, so treating "left out"
|
|
1152
|
+
as "none" would silently straighten a loop a person drew, on a call the
|
|
1153
|
+
agent made to add one stage. */
|
|
1154
|
+
if (exits === undefined) {
|
|
1155
|
+
throw new Error('exits is the whole new list of conditional ways back and may be empty; send [] for none. '
|
|
1156
|
+
+ 'Nothing was written.');
|
|
1157
|
+
}
|
|
1158
|
+
const workflow = await readWorkflow(caller.client, workflow_id);
|
|
1159
|
+
if (workflow.archivedAt !== null) {
|
|
1160
|
+
throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
|
|
1161
|
+
}
|
|
1162
|
+
/* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT,
|
|
1163
|
+
═══ as `reword_stage` does it: no v3 tool at any level takes a stage
|
|
1164
|
+
uuid, so `{ keep: N }` is `get_workflow`'s numbering and the mapping
|
|
1165
|
+
stays here. */
|
|
1166
|
+
const kept = new Set();
|
|
1167
|
+
const entries = stages.map((stage, i) => {
|
|
1168
|
+
if ('keep' in stage) {
|
|
1169
|
+
const target = workflow.stages[stage.keep - 1];
|
|
1170
|
+
if (!target) {
|
|
1171
|
+
throw new Error(`keep ${stage.keep} names a stage ${workflow.name} does not have (stages are `
|
|
1172
|
+
+ `1..${workflow.stages.length}). Nothing was written.`);
|
|
1173
|
+
}
|
|
1174
|
+
if (kept.has(stage.keep)) {
|
|
1175
|
+
throw new Error(`stage ${stage.keep} is listed twice. A stage may appear once. Nothing was written.`);
|
|
1176
|
+
}
|
|
1177
|
+
kept.add(stage.keep);
|
|
1178
|
+
return { stage_id: target.id };
|
|
1179
|
+
}
|
|
1180
|
+
if (stage.name.trim() === '') {
|
|
1181
|
+
throw new Error(`the new stage at position ${i + 1} needs a name. Nothing was written.`);
|
|
1182
|
+
}
|
|
1183
|
+
/* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
|
|
1184
|
+
return { name: stage.name.trim(), description: stage.description?.trim() ?? '', body: stage.body };
|
|
1185
|
+
});
|
|
1186
|
+
const count = await editWorkflow(caller.client, {
|
|
1187
|
+
workflowId: workflow.id,
|
|
1188
|
+
name: workflowName ?? null,
|
|
1189
|
+
/* A WHITESPACE-ONLY DESCRIPTION CLEARS IT, as `reword_stage`'s does;
|
|
1190
|
+
left out, it is left as it is, which is what null means to the RPC. */
|
|
1191
|
+
description: description?.trim() ?? null,
|
|
1192
|
+
stages: entries,
|
|
1193
|
+
exits: zeroBasedExits(exits, entries.length),
|
|
1194
|
+
/* THE BRANCHES GO BACK AS THEY CAME. The RPC rewrites them from what it
|
|
1195
|
+
is sent and nothing in v3 sets one, so sending anything else would
|
|
1196
|
+
delete what a person set on `/workflows`. */
|
|
1197
|
+
branches: workflow.branches,
|
|
1198
|
+
fromAgent: harness(),
|
|
1199
|
+
});
|
|
1200
|
+
/* THE LABEL IS THE NAME THAT SURVIVED THE EDIT, which is the reply's own:
|
|
1201
|
+
`name` is optional and is undefined whenever only the stages changed,
|
|
1202
|
+
so the raw argument would put an empty label on the card. */
|
|
1203
|
+
await receiptOnce(caller, 'workflow', workflow.id, workflowName ?? workflow.name);
|
|
1204
|
+
/* THE COUNT IN THE REPLY IS THE LIST'S, NOT THE RPC'S. They agree when
|
|
1205
|
+
the RPC did its job, and the RPC's is checked for being a number
|
|
1206
|
+
above; the sentence claims what was sent, so it counts what was sent. */
|
|
1207
|
+
return `Edited workflow ${workflowName ?? workflow.name}: it now has exactly the ${entries.length} stages you sent, `
|
|
1208
|
+
+ `in that order, and ${exits.length === 0 ? 'no exits back to an earlier stage' : 'exactly the exits back you sent'}. `
|
|
1209
|
+
+ `The database counts ${count}.`;
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
name: 'duplicate_workflow',
|
|
1214
|
+
/* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ It is the way past
|
|
1215
|
+
the refusal `edit_workflow` gives, so it belongs to the level that was
|
|
1216
|
+
refused. */
|
|
1217
|
+
levels: [2],
|
|
1218
|
+
description: 'Copy a workflow into a new one with no runs: the same stages in the same order, the same '
|
|
1219
|
+
+ 'exits, branch conditions and ending, under a new name (the original name plus " (copy)" '
|
|
1220
|
+
+ 'unless you give one). This is the way past a workflow edit_workflow refused because a work '
|
|
1221
|
+
+ 'item is running it: the original keeps its runs, the copy takes your change. Never put an '
|
|
1222
|
+
+ 'absolute local path in the name.',
|
|
1223
|
+
input: {
|
|
1224
|
+
workflow_id: z.string(),
|
|
1225
|
+
name: z.string().optional().describe('the name for the copy; leave out for the original name plus " (copy)"'),
|
|
1226
|
+
},
|
|
1227
|
+
handler: async (caller, args) => {
|
|
1228
|
+
const { workflow_id, name } = args;
|
|
1229
|
+
const copyName = name?.trim();
|
|
1230
|
+
if (copyName === '') {
|
|
1231
|
+
throw new Error('a workflow needs a name; leave name out to take the original name plus " (copy)". '
|
|
1232
|
+
+ 'Nothing was written.');
|
|
1233
|
+
}
|
|
1234
|
+
/* ═══ NOTHING IS READ FIRST. ═══ The RPC refuses an archived source, one
|
|
1235
|
+
that is not the person's and a stageless one in its own words, which are
|
|
1236
|
+
the words v2 agents already see; a pre-check here would be a second
|
|
1237
|
+
owner of those rules saying them differently. `create_workflow`'s
|
|
1238
|
+
pre-checks exist only because the RPC's numbers are 0-based, and nothing
|
|
1239
|
+
here carries a number. */
|
|
1240
|
+
const id = await duplicateWorkflow(caller.client, {
|
|
1241
|
+
workflowId: workflow_id,
|
|
1242
|
+
name: copyName ?? null,
|
|
1243
|
+
fromAgent: harness(),
|
|
1244
|
+
});
|
|
1245
|
+
/* ═══ READ BACK, NOT GUESSED. ═══ The RPC chose the name (the default is
|
|
1246
|
+
its rule, not this tool's) and copied the links, so the receipt label
|
|
1247
|
+
and the reply say what is stored. */
|
|
1248
|
+
const copy = await readWorkflow(caller.client, id);
|
|
1249
|
+
await receipt(caller, 'workflow', copy.id, copy.name);
|
|
1250
|
+
return `Copied the workflow as ${copy.name}, id ${copy.id}, with ${copy.stages.length} stages`
|
|
1251
|
+
+ `${copy.ending === 'next-in-backlog' ? ', restarting on the next backlog item' : ''}. `
|
|
1252
|
+
+ 'It has no runs, so edit_workflow can change its shape. It links the same stage documents '
|
|
1253
|
+
+ 'as the original, so reword_stage on either rewords both.';
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
840
1256
|
{
|
|
841
1257
|
name: 'get_credential',
|
|
842
1258
|
/* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
|
|
@@ -1316,7 +1732,8 @@ const TOOLS = [
|
|
|
1316
1732
|
name: 'create_work_item',
|
|
1317
1733
|
levels: [1],
|
|
1318
1734
|
description: 'Create a work item on the board. Put what is to be done in the description: a name alone leaves '
|
|
1319
|
-
+ 'whoever picks it up guessing.'
|
|
1735
|
+
+ 'whoever picks it up guessing. '
|
|
1736
|
+
+ FIREWALL_WRITING_RULE,
|
|
1320
1737
|
input: {
|
|
1321
1738
|
project_id: z.string(),
|
|
1322
1739
|
name: z.string().min(1),
|
|
@@ -1377,7 +1794,8 @@ const TOOLS = [
|
|
|
1377
1794
|
work items, and an agent that cannot correct the item it just made in the
|
|
1378
1795
|
same turn would have to make a second one beside it. */
|
|
1379
1796
|
levels: ALL,
|
|
1380
|
-
description: 'Change a work item. Pass only what changes; anything you leave out stays as it is.'
|
|
1797
|
+
description: 'Change a work item. Pass only what changes; anything you leave out stays as it is. '
|
|
1798
|
+
+ FIREWALL_WRITING_RULE,
|
|
1381
1799
|
input: {
|
|
1382
1800
|
work_item_id: z.string(),
|
|
1383
1801
|
name: z.string().min(1).optional(),
|
|
@@ -1387,7 +1805,7 @@ const TOOLS = [
|
|
|
1387
1805
|
sprint_id: z.string().optional(),
|
|
1388
1806
|
due_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
1389
1807
|
},
|
|
1390
|
-
handler: async (
|
|
1808
|
+
handler: async (caller, args) => {
|
|
1391
1809
|
const { work_item_id, ...rest } = args;
|
|
1392
1810
|
const changes = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
|
|
1393
1811
|
/* AN UPDATE WITH NOTHING IN IT IS REFUSED, not quietly treated as a
|
|
@@ -1396,7 +1814,8 @@ const TOOLS = [
|
|
|
1396
1814
|
if (Object.keys(changes).length === 0) {
|
|
1397
1815
|
throw new Error('nothing to change: pass at least one field besides work_item_id');
|
|
1398
1816
|
}
|
|
1399
|
-
const item = await only(client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
|
|
1817
|
+
const item = await only(caller.client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
|
|
1818
|
+
await receiptOnce(caller, 'work_item', item.id, item.name);
|
|
1400
1819
|
return `Updated ${item.name}: ${Object.keys(changes).join(', ')}.`;
|
|
1401
1820
|
},
|
|
1402
1821
|
},
|
|
@@ -1406,12 +1825,15 @@ const TOOLS = [
|
|
|
1406
1825
|
// writes down what it learned so a later stage can read it.
|
|
1407
1826
|
levels: ALL,
|
|
1408
1827
|
description: 'Attach a document to a work item: a plan, a spec, an analysis. Artifacts are always on a work '
|
|
1409
|
-
+ 'item, so create the work item first if there is not one yet.'
|
|
1828
|
+
+ 'item, so create the work item first if there is not one yet. `format` defaults to md. Use '
|
|
1829
|
+
+ 'html for an interactive mock or wireframe, svg for a diagram, json for structured data. '
|
|
1830
|
+
+ FIREWALL_WRITING_RULE,
|
|
1410
1831
|
input: {
|
|
1411
1832
|
work_item_id: z.string(),
|
|
1412
1833
|
title: z.string().min(1),
|
|
1413
1834
|
content: z.string().min(1),
|
|
1414
1835
|
type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
|
|
1836
|
+
format: z.enum(['md', 'html', 'json', 'svg']).optional(),
|
|
1415
1837
|
},
|
|
1416
1838
|
handler: async (caller, args) => {
|
|
1417
1839
|
const a = args;
|
|
@@ -1420,7 +1842,7 @@ const TOOLS = [
|
|
|
1420
1842
|
title: a.title,
|
|
1421
1843
|
content: a.content,
|
|
1422
1844
|
type: a.type ?? 'plan',
|
|
1423
|
-
format: 'md',
|
|
1845
|
+
format: a.format ?? 'md',
|
|
1424
1846
|
// `not null`, no default, and the table's own policy requires it to be
|
|
1425
1847
|
// the caller. Stated rather than left to be defaulted somewhere else.
|
|
1426
1848
|
created_by: caller.userId,
|
|
@@ -1434,18 +1856,27 @@ const TOOLS = [
|
|
|
1434
1856
|
// Everyone's, for the same reason `create_artifact` is: an object that can
|
|
1435
1857
|
// be made and never corrected is an object that goes stale in the record.
|
|
1436
1858
|
levels: ALL,
|
|
1437
|
-
description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete.'
|
|
1438
|
-
|
|
1439
|
-
|
|
1859
|
+
description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete. '
|
|
1860
|
+
+ 'Pass `format` with the complete body to change how it renders. '
|
|
1861
|
+
+ FIREWALL_WRITING_RULE,
|
|
1862
|
+
input: {
|
|
1863
|
+
artifact_id: z.string(),
|
|
1864
|
+
content: z.string().min(1),
|
|
1865
|
+
title: z.string().min(1).optional(),
|
|
1866
|
+
format: z.enum(['md', 'html', 'json', 'svg']).optional(),
|
|
1867
|
+
},
|
|
1868
|
+
handler: async (caller, args) => {
|
|
1440
1869
|
const a = args;
|
|
1441
|
-
const artifact = await only(client.from('artifacts')
|
|
1870
|
+
const artifact = await only(caller.client.from('artifacts')
|
|
1442
1871
|
.update({
|
|
1443
1872
|
content: a.content,
|
|
1444
1873
|
...(a.title ? { title: a.title } : {}),
|
|
1874
|
+
...(a.format ? { format: a.format } : {}),
|
|
1445
1875
|
updated_at: new Date().toISOString(),
|
|
1446
|
-
updated_by: userId,
|
|
1876
|
+
updated_by: caller.userId,
|
|
1447
1877
|
})
|
|
1448
1878
|
.eq('id', a.artifact_id).select('id, title'), 'update', `artifact ${a.artifact_id}`);
|
|
1879
|
+
await receiptOnce(caller, 'artifact', artifact.id, artifact.title);
|
|
1449
1880
|
return `Updated artifact ${artifact.title ?? artifact.id}.`;
|
|
1450
1881
|
},
|
|
1451
1882
|
},
|
|
@@ -1471,7 +1902,8 @@ const TOOLS = [
|
|
|
1471
1902
|
+ 'convention agreed, how a subsystem actually fits together, why an approach was rejected. '
|
|
1472
1903
|
+ 'It adds a document and never changes or removes one, so do not use it to correct something '
|
|
1473
1904
|
+ 'already written; say what is wrong in your report instead. Not for the work you were sent '
|
|
1474
|
-
+ 'to do — that goes in an artifact on the work item.'
|
|
1905
|
+
+ 'to do — that goes in an artifact on the work item. '
|
|
1906
|
+
+ FIREWALL_WRITING_RULE,
|
|
1475
1907
|
input: {
|
|
1476
1908
|
title: z.string().min(1).max(100).describe('What this document is, in a few words a teammate would search for. 100 characters at most.'),
|
|
1477
1909
|
type: z.enum(['instructions', 'architecture', 'design', 'conventions', 'other']).describe('Which kind of reference this is, matching the five the web app offers.'),
|
|
@@ -1543,7 +1975,8 @@ const TOOLS = [
|
|
|
1543
1975
|
+ 'what you are about to do and what they will get. Use it again if what you are doing changes '
|
|
1544
1976
|
+ 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
|
|
1545
1977
|
+ 'and does not replace the reply or the question you finish with: keep working after it. Do '
|
|
1546
|
-
+ 'not use it for a running commentary, and do not use it to ask anything.'
|
|
1978
|
+
+ 'not use it for a running commentary, and do not use it to ask anything. '
|
|
1979
|
+
+ FIREWALL_WRITING_RULE,
|
|
1547
1980
|
input: {
|
|
1548
1981
|
message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
|
|
1549
1982
|
+ 'the plan and bring it to you."'),
|
|
@@ -1565,7 +1998,7 @@ const TOOLS = [
|
|
|
1565
1998
|
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1566
1999
|
});
|
|
1567
2000
|
if (error)
|
|
1568
|
-
throw new Error(`could not say that on this card: ${error.message}`);
|
|
2001
|
+
throw new Error(`could not say that on this card: ${readableWriteError(error.message)}`);
|
|
1569
2002
|
/* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
|
|
1570
2003
|
SPOKE. ═══ Three reasons collapse to one sentence because the caller
|
|
1571
2004
|
cannot tell them apart and all three mean the same thing to it: this
|
|
@@ -1579,6 +2012,272 @@ const TOOLS = [
|
|
|
1579
2012
|
return 'Said. They can read it now. Carry on.';
|
|
1580
2013
|
},
|
|
1581
2014
|
},
|
|
2015
|
+
// ── Show them a picture ──────────────────────────────────────────────────
|
|
2016
|
+
//
|
|
2017
|
+
// ═══ LEVELS 2 AND 3, AND THEY REACH THE CARD BY DIFFERENT DOORS. ═══
|
|
2018
|
+
//
|
|
2019
|
+
// A level 2 owner SPEAKS: its picture goes into a turn through `panel3_say`,
|
|
2020
|
+
// which refuses any run that is not the one `panel3_cards.conversation_run_id`
|
|
2021
|
+
// names, so the fence is enforced in SQL rather than by this list.
|
|
2022
|
+
//
|
|
2023
|
+
// A level 3 worker CANNOT speak, and `panel3_say`'s own comment says why: it
|
|
2024
|
+
// exists so "a dispatched worker can never reach the person through it".
|
|
2025
|
+
// Widening that fence to carry a picture would change what a worker is. So a
|
|
2026
|
+
// worker writes the `panel3_images` row with NO turn, and the panel reads a
|
|
2027
|
+
// card's turn-less images as conversation events of their own.
|
|
2028
|
+
//
|
|
2029
|
+
// ═══ AN IMAGE DOES NOT NEED A TURN TO EXIST, WHICH IS WHAT MAKES THAT
|
|
2030
|
+
// POSSIBLE. ═══ `panel3_images.turn_id` is nullable for exactly this case, and
|
|
2031
|
+
// the two reads partition the table rather than overlapping it: the turn embed
|
|
2032
|
+
// takes the rows that have a `turn_id`, the card read takes the rows that do
|
|
2033
|
+
// not. Neither picture can render twice, by construction.
|
|
2034
|
+
{
|
|
2035
|
+
name: 'attach_image',
|
|
2036
|
+
levels: [2, 3],
|
|
2037
|
+
description: 'Show the person a picture on the card, with one or two sentences saying what they are '
|
|
2038
|
+
+ 'looking at. Use it when a picture is the answer and words are not: a screen you have just '
|
|
2039
|
+
+ 'changed, a diagram, a chart, something you were asked to look at. The picture must be a PNG '
|
|
2040
|
+
+ 'file that already exists on this machine, and you give its absolute path. It does NOT end '
|
|
2041
|
+
+ 'your turn: keep working after it. Do not use it for a picture they '
|
|
2042
|
+
+ 'already have, and do not use it in place of the reply you finish with.',
|
|
2043
|
+
input: {
|
|
2044
|
+
path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
|
|
2045
|
+
caption: z.string().min(1).max(PERSON_READS_LIMIT).describe('What they are looking at, in their words. "Here is the search screen with the new filter '
|
|
2046
|
+
+ 'row." One or two sentences: it is read on a card three inches wide.'),
|
|
2047
|
+
},
|
|
2048
|
+
handler: async ({ client, userId, cardId, runId, level, processToken }, args) => {
|
|
2049
|
+
const { path, caption } = args;
|
|
2050
|
+
/* ═══ THE PICTURE IS VALIDATED BEFORE ANYTHING IS WRITTEN, AND ITS
|
|
2051
|
+
FAILURES ARE RE-THROWN PATH-FREE. ═══ `readPngScreenshot` interpolates
|
|
2052
|
+
the absolute path into every one of its messages, and a tool's thrown
|
|
2053
|
+
message is what the agent reads. An agent may quote its own error into a
|
|
2054
|
+
`say`, which lands in `panel3_turns.body` — a cloud row a browser
|
|
2055
|
+
renders. So the reason is kept and the path is not, in the shape
|
|
2056
|
+
`checkout.ts` established for exactly this. */
|
|
2057
|
+
let picture;
|
|
2058
|
+
try {
|
|
2059
|
+
picture = await readPngScreenshot(path);
|
|
2060
|
+
}
|
|
2061
|
+
catch (err) {
|
|
2062
|
+
/* ═══ THE REASON IS KEPT, THE TEXT IS NOT. ═══ Every message from below
|
|
2063
|
+
may name the path, and not only where `readPngScreenshot` interpolated
|
|
2064
|
+
it: an `ENOENT` from Node restates the path in its own words, inside
|
|
2065
|
+
quotes, and a `replace(path, …)` over the string would miss any such
|
|
2066
|
+
restatement that differs by a byte. So nothing composed down there is
|
|
2067
|
+
forwarded. What the agent needs is which of these went wrong, and each
|
|
2068
|
+
of these sentences is written here. */
|
|
2069
|
+
throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
|
|
2070
|
+
}
|
|
2071
|
+
/* ═══ THE ID IS MINTED HERE, BECAUSE THE STORAGE PATH IS DERIVED FROM IT.
|
|
2072
|
+
═══ `panel3_images_storage_path_shape` requires the object key to equal
|
|
2073
|
+
`<user>/<card>/<id>.png`, so a server-defaulted id makes the key
|
|
2074
|
+
uncomputable before the upload. */
|
|
2075
|
+
const imageId = crypto.randomUUID();
|
|
2076
|
+
const storagePath = `${userId}/${cardId}/${imageId}.png`;
|
|
2077
|
+
const { error: uploadError } = await client.storage
|
|
2078
|
+
.from(PANEL3_IMAGES_BUCKET)
|
|
2079
|
+
.upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
|
|
2080
|
+
if (uploadError) {
|
|
2081
|
+
throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
|
|
2082
|
+
}
|
|
2083
|
+
/* ═══ A WORKER WRITES THE PICTURE AND NOTHING ELSE. ═══ It has no turn to
|
|
2084
|
+
hang it on and must not acquire one: `panel3_say` would refuse it, and
|
|
2085
|
+
it is right to. The row's null `turn_id` is what puts the picture on the
|
|
2086
|
+
card as an event of its own, and the caption travels with it as the
|
|
2087
|
+
row's own words rather than as a message the worker did not send. */
|
|
2088
|
+
if (level === 3) {
|
|
2089
|
+
/* ═══ AND IT ASKS FIRST WHETHER IT IS STILL RUNNING, WHICH LEVEL 2 GETS
|
|
2090
|
+
FOR FREE AND THIS DOES NOT. ═══ `panel3_say` refuses a stopped card
|
|
2091
|
+
and a superseded owner, so the level 2 path below cannot write a
|
|
2092
|
+
picture from a run that is over. A worker has no such gate: its row
|
|
2093
|
+
can be stamped `stopped` by the person's Stop button while its process
|
|
2094
|
+
lives on for another poll, and a bare insert here would put a picture
|
|
2095
|
+
on the card after they asked for it to end. */
|
|
2096
|
+
if (!(await stillRunning(client, runId))) {
|
|
2097
|
+
throw new Error('NOTHING WAS WRITTEN: THIS RUN HAS ENDED, so nothing more of yours reaches the card. Stop.');
|
|
2098
|
+
}
|
|
2099
|
+
const { error } = await client.from('panel3_images').insert({
|
|
2100
|
+
id: imageId,
|
|
2101
|
+
card_id: cardId,
|
|
2102
|
+
turn_id: null,
|
|
2103
|
+
caption,
|
|
2104
|
+
storage_path: storagePath,
|
|
2105
|
+
width: picture.width,
|
|
2106
|
+
height: picture.height,
|
|
2107
|
+
size_bytes: picture.sizeBytes,
|
|
2108
|
+
});
|
|
2109
|
+
if (error) {
|
|
2110
|
+
throw new Error(`NOTHING WAS WRITTEN: the picture could not be recorded (${error.message}).`);
|
|
2111
|
+
}
|
|
2112
|
+
return 'Shown on the card. Carry on with your own work: this did not send anybody a message.';
|
|
2113
|
+
}
|
|
2114
|
+
/* ═══ SPEAK FIRST, THEN WRITE THE ROW. ═══ NOT the web send path's order,
|
|
2115
|
+
and the difference is `panel3_say`'s: it returns NULL rather than
|
|
2116
|
+
raising when the card is stopped or this owner has been superseded. An
|
|
2117
|
+
image row written before that refusal would sit on the card with a null
|
|
2118
|
+
`turn_id`, which is now a WORKER's picture and would be rendered as one.
|
|
2119
|
+
Speaking first means a refusal happens before any row exists. The upload
|
|
2120
|
+
still precedes both, so a refusal leaves bytes with no row, which is
|
|
2121
|
+
invisible and is the honest way round: the alternative is a row pointing
|
|
2122
|
+
at bytes that were never stored. */
|
|
2123
|
+
const { data: turnId, error: sayError } = await client.rpc('panel3_say', {
|
|
2124
|
+
p_run_id: runId,
|
|
2125
|
+
p_body: caption,
|
|
2126
|
+
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
2127
|
+
});
|
|
2128
|
+
if (sayError)
|
|
2129
|
+
throw new Error(`could not show that on this card: ${sayError.message}`);
|
|
2130
|
+
if (turnId === null) {
|
|
2131
|
+
throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
|
|
2132
|
+
+ 'or somebody else owns it now. Do not tell anybody you showed them anything.');
|
|
2133
|
+
}
|
|
2134
|
+
const { error: rowError } = await client.from('panel3_images').insert({
|
|
2135
|
+
id: imageId,
|
|
2136
|
+
card_id: cardId,
|
|
2137
|
+
turn_id: turnId,
|
|
2138
|
+
/* ═══ NULL WHEN THERE IS A TURN, BECAUSE THE TURN IS ALREADY THE WORDS.
|
|
2139
|
+
═══ Storing the caption twice would give the panel two places to read
|
|
2140
|
+
the same sentence from and a way for them to disagree. */
|
|
2141
|
+
caption: null,
|
|
2142
|
+
storage_path: storagePath,
|
|
2143
|
+
width: picture.width,
|
|
2144
|
+
height: picture.height,
|
|
2145
|
+
size_bytes: picture.sizeBytes,
|
|
2146
|
+
});
|
|
2147
|
+
/* Truthful rather than tidy: the caption is already in front of the person,
|
|
2148
|
+
so the failure says what they can see and what they cannot. */
|
|
2149
|
+
if (rowError) {
|
|
2150
|
+
throw new Error(`Your words reached them but the picture did not (${rowError.message}). They are looking `
|
|
2151
|
+
+ 'at a message with nothing under it, so say what it was meant to show.');
|
|
2152
|
+
}
|
|
2153
|
+
return 'Shown. They can see the picture and read what you said about it. Carry on.';
|
|
2154
|
+
},
|
|
2155
|
+
},
|
|
2156
|
+
// ── Keep a picture on the work item ──────────────────────────────────────
|
|
2157
|
+
//
|
|
2158
|
+
// ═══ A SECOND TOOL, NOT FIVE INPUTS ON THE FIRST. ═══ Anchoring a picture to
|
|
2159
|
+
// a work item needs a title, a platform and a target that `attach_image` has
|
|
2160
|
+
// no use for, and `attach_image` needs a caption this has no use for. Bolting
|
|
2161
|
+
// them together makes five inputs, three of them conditional on each other,
|
|
2162
|
+
// and a schema that cannot express its own rule. `attach_screenshot` is the
|
|
2163
|
+
// shape followed here: every input required, one write path.
|
|
2164
|
+
//
|
|
2165
|
+
// ═══ AND THE ORG-SCOPED BUCKET IS RIGHT HERE, HAVING BEEN WRONG NEXT DOOR.
|
|
2166
|
+
// ═══ `attach_image` writes to the private owner-scoped `panel3-images`,
|
|
2167
|
+
// because a conversation is one person's. An artifact belongs to a work item
|
|
2168
|
+
// the whole org can see, so it goes where every other artifact goes. Two
|
|
2169
|
+
// visibility models, two buckets, both deliberate.
|
|
2170
|
+
{
|
|
2171
|
+
name: 'attach_image_artifact',
|
|
2172
|
+
levels: [2, 3],
|
|
2173
|
+
description: 'Keep a picture on the work item, where anybody can find it later. Use it when the picture is '
|
|
2174
|
+
+ 'part of what the work produced rather than something you are showing in passing: a screen '
|
|
2175
|
+
+ 'you have built, a diagram of what you changed, the state something is in. The picture must '
|
|
2176
|
+
+ 'be a PNG file that already exists on this machine, and you give its absolute path. It is '
|
|
2177
|
+
+ 'kept for good and cannot be replaced, so attach it when it is right. Use attach_image '
|
|
2178
|
+
+ 'instead for something the person only needs to see now.',
|
|
2179
|
+
input: {
|
|
2180
|
+
path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
|
|
2181
|
+
title: z.string().min(1).max(200).describe('What this picture is, as a person scanning the work item would want it named. '
|
|
2182
|
+
+ '"Search results with the new filter row."'),
|
|
2183
|
+
work_item_id: z.string().uuid().describe('The work item to keep it on. It must be one this card carries: get_my_brief_and_report '
|
|
2184
|
+
+ 'names it.'),
|
|
2185
|
+
platform: z.enum(['web', 'ios', 'android']).describe('Where the picture was taken.'),
|
|
2186
|
+
target: z.string().min(1).max(200).describe('What was on screen, so somebody can find their way back to it. A URL, a route, or a screen '
|
|
2187
|
+
+ 'name. "/projects/search" or "Search results".'),
|
|
2188
|
+
},
|
|
2189
|
+
handler: async (caller, args) => {
|
|
2190
|
+
const { path, title, work_item_id, platform, target } = args;
|
|
2191
|
+
const { client, userId } = caller;
|
|
2192
|
+
let picture;
|
|
2193
|
+
try {
|
|
2194
|
+
picture = await readPngScreenshot(path);
|
|
2195
|
+
}
|
|
2196
|
+
catch {
|
|
2197
|
+
/* Path-free, for `attach_image`'s reason: a tool's thrown message is what
|
|
2198
|
+
the agent reads, and an agent quotes its own errors into a `say`. */
|
|
2199
|
+
throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
|
|
2200
|
+
}
|
|
2201
|
+
/* ═══ THE WORK ITEM MUST BE ONE THIS CARD CARRIES. ═══ Validated once,
|
|
2202
|
+
here, at the boundary. Without it an agent that names the wrong id
|
|
2203
|
+
writes a picture onto an unrelated work item, which nothing downstream
|
|
2204
|
+
would catch: the guard checks the artifact's SHAPE, and RLS lets this
|
|
2205
|
+
person write to any item in their org. The card's attachments are what
|
|
2206
|
+
the PERSON pointed this conversation at, so they are the authority on
|
|
2207
|
+
what this run may anchor to. */
|
|
2208
|
+
const attached = await loadAttachments(client, caller.cardId);
|
|
2209
|
+
if (!attached.some((item) => item.kind === 'work_item' && item.ref_id === work_item_id)) {
|
|
2210
|
+
throw new Error(`NOTHING WAS WRITTEN: this card does not carry work item ${work_item_id}, so a picture `
|
|
2211
|
+
+ 'cannot be kept on it. get_my_brief_and_report names the work item you are on. If the '
|
|
2212
|
+
+ 'picture belongs to the conversation rather than to an item, use attach_image.');
|
|
2213
|
+
}
|
|
2214
|
+
/* ═══ THE DATABASE DERIVES THE STORAGE PATH AND REFUSES ANY OTHER. ═══
|
|
2215
|
+
`app.guard_agent_artifact_insert` computes
|
|
2216
|
+
`<org>/<project>/<task>/<artifact id>.png` and raises on a mismatch, so
|
|
2217
|
+
the org and project are resolved BEFORE the upload rather than after:
|
|
2218
|
+
the key cannot be computed without them, and a server-defaulted artifact
|
|
2219
|
+
id would make it uncomputable at all. */
|
|
2220
|
+
const item = await only(client.from('tasks').select('id, project_id').eq('id', work_item_id).is('archived_at', null), 'read', `work item ${work_item_id}`);
|
|
2221
|
+
if (item.project_id === null) {
|
|
2222
|
+
throw new Error(`NOTHING WAS WRITTEN: work item ${work_item_id} is in no project, and a picture is kept `
|
|
2223
|
+
+ 'under its project. Move it into one first.');
|
|
2224
|
+
}
|
|
2225
|
+
const project = await only(client.from('projects').select('id, org_id').eq('id', item.project_id).is('archived_at', null), 'read', `the project work item ${work_item_id} is in`);
|
|
2226
|
+
/* ═══ CONTENT-ADDRESSED, NOT MINTED. ═══ The same picture under the same
|
|
2227
|
+
anchor and title yields the same id, so a call interrupted between the
|
|
2228
|
+
upload and the insert retries onto the same key and converges instead of
|
|
2229
|
+
leaving one orphaned object per attempt. A random uuid would make every
|
|
2230
|
+
retry a second artifact of the same picture. */
|
|
2231
|
+
const artifactId = screenshotArtifactId(item.id, title, platform, target, picture.bytes);
|
|
2232
|
+
const storagePath = `${project.org_id}/${item.project_id}/${item.id}/${artifactId}.png`;
|
|
2233
|
+
/* ═══ EXACTLY THESE FIVE KEYS, BECAUSE THE GUARD DEMANDS EXACTLY THESE
|
|
2234
|
+
FIVE. ═══ It rejects a `content` object with any other key set, so this
|
|
2235
|
+
is not a place to add a field. */
|
|
2236
|
+
const content = JSON.stringify({
|
|
2237
|
+
platform,
|
|
2238
|
+
target,
|
|
2239
|
+
width: picture.width,
|
|
2240
|
+
height: picture.height,
|
|
2241
|
+
size_bytes: picture.sizeBytes,
|
|
2242
|
+
});
|
|
2243
|
+
const { error: uploadError } = await client.storage
|
|
2244
|
+
.from('artifacts')
|
|
2245
|
+
.upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
|
|
2246
|
+
/* A duplicate key is the converging retry above, arriving: the bytes at
|
|
2247
|
+
that key are this picture's, because the key is derived from them. */
|
|
2248
|
+
if (uploadError && !/duplicate|already exists|resource exists/i.test(uploadError.message)) {
|
|
2249
|
+
throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
|
|
2250
|
+
}
|
|
2251
|
+
const { error: insertError } = await client.from('artifacts').insert({
|
|
2252
|
+
id: artifactId,
|
|
2253
|
+
task_id: item.id,
|
|
2254
|
+
type: 'image',
|
|
2255
|
+
format: 'png',
|
|
2256
|
+
title,
|
|
2257
|
+
content,
|
|
2258
|
+
storage_path: storagePath,
|
|
2259
|
+
created_by: userId,
|
|
2260
|
+
/* ═══ BOTH NULL, AND THE GUARD IS WHY. ═══ It refuses a non-null
|
|
2261
|
+
`from_agent` from an `authenticated` caller, and panel3's client is
|
|
2262
|
+
the person's own session. `create_artifact` leaves them null for the
|
|
2263
|
+
same reason. */
|
|
2264
|
+
from_agent: null,
|
|
2265
|
+
agent_run_id: null,
|
|
2266
|
+
});
|
|
2267
|
+
if (insertError) {
|
|
2268
|
+
/* A retry that finds its own artifact already there has converged, which
|
|
2269
|
+
is the point of the derived id: say so rather than reporting a
|
|
2270
|
+
failure that would make the agent attach a second copy. */
|
|
2271
|
+
if (/duplicate key|already exists/i.test(insertError.message)) {
|
|
2272
|
+
return `Already kept on the work item as "${title}". Nothing was written twice.`;
|
|
2273
|
+
}
|
|
2274
|
+
throw new Error(`the picture could not be kept on the work item (${insertError.message}).`);
|
|
2275
|
+
}
|
|
2276
|
+
await receipt(caller, 'artifact', artifactId, title);
|
|
2277
|
+
return (`Kept on the work item as "${title}", and it shows on the card. It cannot be replaced, so `
|
|
2278
|
+
+ 'attach another if this one turns out to be wrong.');
|
|
2279
|
+
},
|
|
2280
|
+
},
|
|
1582
2281
|
// ── Report ───────────────────────────────────────────────────────────────
|
|
1583
2282
|
{
|
|
1584
2283
|
name: 'report_activity',
|
|
@@ -1617,7 +2316,8 @@ const TOOLS = [
|
|
|
1617
2316
|
description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
|
|
1618
2317
|
+ 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
|
|
1619
2318
|
+ 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
|
|
1620
|
-
+ 'you go, not only at the end.'
|
|
2319
|
+
+ 'you go, not only at the end. '
|
|
2320
|
+
+ FIREWALL_WRITING_RULE,
|
|
1621
2321
|
input: { report: z.string().min(1) },
|
|
1622
2322
|
handler: async ({ client, runId, processToken }, args) => {
|
|
1623
2323
|
const { report } = args;
|
|
@@ -1767,7 +2467,7 @@ const TOOLS = [
|
|
|
1767
2467
|
...(processToken === undefined ? {} : { p_process_token: processToken }),
|
|
1768
2468
|
});
|
|
1769
2469
|
if (error)
|
|
1770
|
-
throw new Error(`could not answer question ${question_id}: ${error.message}`);
|
|
2470
|
+
throw new Error(`could not answer question ${question_id}: ${readableWriteError(error.message)}`);
|
|
1771
2471
|
if (data === null) {
|
|
1772
2472
|
/* REFUSED, AND THE THREE REASONS ARE ONE SENTENCE because the caller
|
|
1773
2473
|
cannot tell them apart and all three mean the same thing to it: this
|
|
@@ -1814,12 +2514,34 @@ const TOOLS = [
|
|
|
1814
2514
|
+ 'context: what to find out or change, and in which part of the codebase.'),
|
|
1815
2515
|
boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
|
|
1816
2516
|
work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
|
|
2517
|
+
work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
|
|
2518
|
+
+ 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
|
|
2519
|
+
+ 'attached: the conversation is called this from now on, and the branch the work goes on is '
|
|
2520
|
+
+ 'cut from it, both at the moment you send somebody. Leave it out when a work item is '
|
|
2521
|
+
+ 'attached, because that item is already the name.'),
|
|
1817
2522
|
},
|
|
1818
2523
|
handler: async (caller, args) => {
|
|
1819
|
-
const { codebase_id, responsibility, boundary, work_item_id } = args;
|
|
2524
|
+
const { codebase_id, responsibility, boundary, work_item_id, work_name } = args;
|
|
1820
2525
|
if (caller.level === 2 && !codebase_id) {
|
|
1821
2526
|
throw new Error('A worker must be attached to a registered project codebase.');
|
|
1822
2527
|
}
|
|
2528
|
+
/* ═══ THE NAME IS CHECKED BEFORE ANYTHING IS READ, WRITTEN OR STARTED.
|
|
2529
|
+
═══ It is the one argument here that changes something the person is
|
|
2530
|
+
already looking at, so a refusal has to leave the card exactly as it
|
|
2531
|
+
was and nobody running. */
|
|
2532
|
+
const named = work_name === undefined ? null : work_name.trim();
|
|
2533
|
+
if (named !== null) {
|
|
2534
|
+
const words = named === '' ? [] : named.split(/\s+/);
|
|
2535
|
+
if (words.length === 0) {
|
|
2536
|
+
throw new Error('NOTHING WAS WRITTEN and nobody was started: work_name is blank. Name the work in a few '
|
|
2537
|
+
+ 'words, or leave it out and the conversation keeps the words they opened with.');
|
|
2538
|
+
}
|
|
2539
|
+
if (words.length > WORK_NAME_WORDS) {
|
|
2540
|
+
throw new Error(`NOTHING WAS WRITTEN and nobody was started: that is ${words.length} words, and it is `
|
|
2541
|
+
+ `read on one line and cut into a branch name. The limit is ${WORK_NAME_WORDS}. Name `
|
|
2542
|
+
+ 'the work, do not describe it.');
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
1823
2545
|
let codebase = null;
|
|
1824
2546
|
if (codebase_id) {
|
|
1825
2547
|
const projectId = await projectOfCard(caller);
|
|
@@ -1845,7 +2567,21 @@ const TOOLS = [
|
|
|
1845
2567
|
three hops from level 1 still knows which item it is working, without
|
|
1846
2568
|
asking. See `workBrief`'s own doc for why the two are different things
|
|
1847
2569
|
carried the same way. */
|
|
1848
|
-
const
|
|
2570
|
+
const attached = await loadAttachments(caller.client, caller.cardId);
|
|
2571
|
+
/* ═══ THE CONVERSATION IS NAMED HERE OR IT IS NEVER NAMED. ═══ Sending
|
|
2572
|
+
somebody resolves the working copy, and resolving it cuts the branch
|
|
2573
|
+
from whatever the conversation is called at that moment and stamps it
|
|
2574
|
+
for good. So the name is written BEFORE the call below, and there is no
|
|
2575
|
+
second chance further on.
|
|
2576
|
+
|
|
2577
|
+
AN ATTACHED WORK ITEM IS ALREADY THE NAME, so nothing is written over
|
|
2578
|
+
it: the person chose that item and the conversation was called after it
|
|
2579
|
+
the moment they did. */
|
|
2580
|
+
if (named !== null && !attached.some((attachment) => attachment.kind === 'work_item')) {
|
|
2581
|
+
await rows(caller.client.from('panel3_cards').update({ title: named }).eq('id', caller.cardId)
|
|
2582
|
+
.select('id'), 'name', `card ${caller.cardId}`);
|
|
2583
|
+
}
|
|
2584
|
+
const attachments = attached.map(attachmentLine);
|
|
1849
2585
|
const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
|
|
1850
2586
|
id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
|
|
1851
2587
|
}), codebase, caller.processToken);
|
|
@@ -1922,8 +2658,14 @@ const TOOLS = [
|
|
|
1922
2658
|
* that produced nothing visible. So every `create_*` above writes its own
|
|
1923
2659
|
* receipt, and `record_output` stays for what these tools did not create.
|
|
1924
2660
|
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
2661
|
+
* AN EDIT RECORDS THE OBJECT TOO, ONCE. A person who asks for an artifact to be
|
|
2662
|
+
* rewritten wants to open the thing that changed, and a card showing nothing
|
|
2663
|
+
* leaves them nowhere to go; but editing something twice did not produce it
|
|
2664
|
+
* twice. So the four editing tools go through `receiptOnce`, which calls
|
|
2665
|
+
* `receipt` only when this card has no receipt for that object yet. `receipt`
|
|
2666
|
+
* stays the only writer, and the check is deliberately NOT inside it: every
|
|
2667
|
+
* create site hands it an id made a moment ago, which cannot already be there,
|
|
2668
|
+
* and a read for each of them would buy nothing.
|
|
1927
2669
|
*
|
|
1928
2670
|
* ═══ AND AN ENDED RUN DOES NOT LEAVE RECEIPTS, WHICH IS THE ONE GUARD HERE
|
|
1929
2671
|
* THAT IS NOT ATOMIC. ═══
|
|
@@ -1940,18 +2682,50 @@ const TOOLS = [
|
|
|
1940
2682
|
* is a duplicate of work another agent may redo, not an attribution to something
|
|
1941
2683
|
* that never happened, which is why it does not earn an RPC of its own today.
|
|
1942
2684
|
*/
|
|
1943
|
-
|
|
2685
|
+
/**
|
|
2686
|
+
* Whether this run may still put something on the person's card.
|
|
2687
|
+
*
|
|
2688
|
+
* ═══ ONE OWNER, BECAUSE THERE IS ONE QUESTION. ═══ A run's process outlives its
|
|
2689
|
+
* row: `panel3_stop_these` stamps `state` and `ended_at` across a whole subtree
|
|
2690
|
+
* in one statement and the processes are killed within a poll, so between those
|
|
2691
|
+
* two moments a child is alive and its row says it is finished. Every write that
|
|
2692
|
+
* reaches the card asks this before it lands, and it is the same predicate for
|
|
2693
|
+
* all of them, which is why it is a function rather than a clause repeated in
|
|
2694
|
+
* each of them.
|
|
2695
|
+
*/
|
|
2696
|
+
async function stillRunning(client, runId) {
|
|
1944
2697
|
const live = await rows(client.from('panel3_runs').select('id').eq('id', runId)
|
|
1945
2698
|
.in('state', STILL_WRITING).is('ended_at', null), 'check', `whether run ${runId} is still running`);
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2699
|
+
return live.length > 0;
|
|
2700
|
+
}
|
|
2701
|
+
async function receipt({ client, runId, cardId }, kind, refId, label) {
|
|
2702
|
+
if (!(await stillRunning(client, runId))) {
|
|
2703
|
+
/* IT NAMES WHAT LANDED. The product row exists — it was written before this
|
|
2704
|
+
was reached — so an error that only said "refused" would leave the agent
|
|
2705
|
+
unable to say whether the thing is there. IT COVERS BOTH CALLERS: the
|
|
2706
|
+
creating tools and, through `receiptOnce`, the editing ones, so it must
|
|
2707
|
+
not tell an agent it created what it changed. */
|
|
2708
|
+
throw new Error(`the ${kind} ${refId} was written, but THIS RUN HAS ENDED so no receipt was written for it and `
|
|
2709
|
+
+ 'its card has been handed to another agent. Say what you did and stop.');
|
|
1952
2710
|
}
|
|
1953
2711
|
await only(client.from('panel3_outputs').insert({ card_id: cardId, run_id: runId, kind, ref_id: refId, label }).select('id'), 'record', `the ${kind} on the card`);
|
|
1954
2712
|
}
|
|
2713
|
+
/**
|
|
2714
|
+
* The same receipt, unless this card already carries one for that object.
|
|
2715
|
+
*
|
|
2716
|
+
* ═══ THIS IS NOT A RACE GUARD, AND IS NOT CLAIMED AS ONE. ═══ `panel3_outputs`
|
|
2717
|
+
* has no unique key over card, kind and ref, so two edits of the same object
|
|
2718
|
+
* landing in the same instant can both read nothing and both write. What that
|
|
2719
|
+
* costs is one repeated card and nothing worse, which is why it does not earn a
|
|
2720
|
+
* constraint or an RPC today.
|
|
2721
|
+
*/
|
|
2722
|
+
async function receiptOnce(caller, kind, refId, label) {
|
|
2723
|
+
const already = await rows(caller.client.from('panel3_outputs').select('id')
|
|
2724
|
+
.eq('card_id', caller.cardId).eq('kind', kind).eq('ref_id', refId), 'check', `whether the ${kind} ${refId} is already on the card`);
|
|
2725
|
+
if (already.length > 0)
|
|
2726
|
+
return;
|
|
2727
|
+
await receipt(caller, kind, refId, label);
|
|
2728
|
+
}
|
|
1955
2729
|
/** The names one level is served, in the order they are registered. Exported for
|
|
1956
2730
|
* the same reason `cs show` exists: a rule nobody can print is a rule nobody
|
|
1957
2731
|
* can check. */
|