@owlmeans/viable-sdk 0.1.18-rc.1
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/README.md +128 -0
- package/build/api/index.d.ts +2 -0
- package/build/api/index.d.ts.map +1 -0
- package/build/api/index.js +2 -0
- package/build/api/index.js.map +1 -0
- package/build/api/remote.d.ts +14 -0
- package/build/api/remote.d.ts.map +1 -0
- package/build/api/remote.js +123 -0
- package/build/api/remote.js.map +1 -0
- package/build/consts.d.ts +47 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +47 -0
- package/build/consts.js.map +1 -0
- package/build/context/index.d.ts +22 -0
- package/build/context/index.d.ts.map +1 -0
- package/build/context/index.js +92 -0
- package/build/context/index.js.map +1 -0
- package/build/errors.d.ts +21 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +36 -0
- package/build/errors.js.map +1 -0
- package/build/executor/boot-check.d.ts +73 -0
- package/build/executor/boot-check.d.ts.map +1 -0
- package/build/executor/boot-check.js +155 -0
- package/build/executor/boot-check.js.map +1 -0
- package/build/executor/env.d.ts +25 -0
- package/build/executor/env.d.ts.map +1 -0
- package/build/executor/env.js +77 -0
- package/build/executor/env.js.map +1 -0
- package/build/executor/errors.d.ts +69 -0
- package/build/executor/errors.d.ts.map +1 -0
- package/build/executor/errors.js +80 -0
- package/build/executor/errors.js.map +1 -0
- package/build/executor/files.d.ts +124 -0
- package/build/executor/files.d.ts.map +1 -0
- package/build/executor/files.js +529 -0
- package/build/executor/files.js.map +1 -0
- package/build/executor/git.d.ts +52 -0
- package/build/executor/git.d.ts.map +1 -0
- package/build/executor/git.js +353 -0
- package/build/executor/git.js.map +1 -0
- package/build/executor/health.d.ts +92 -0
- package/build/executor/health.d.ts.map +1 -0
- package/build/executor/health.js +98 -0
- package/build/executor/health.js.map +1 -0
- package/build/executor/index.d.ts +39 -0
- package/build/executor/index.d.ts.map +1 -0
- package/build/executor/index.js +173 -0
- package/build/executor/index.js.map +1 -0
- package/build/executor/integrity.d.ts +17 -0
- package/build/executor/integrity.d.ts.map +1 -0
- package/build/executor/integrity.js +59 -0
- package/build/executor/integrity.js.map +1 -0
- package/build/executor/layout.d.ts +61 -0
- package/build/executor/layout.d.ts.map +1 -0
- package/build/executor/layout.js +98 -0
- package/build/executor/layout.js.map +1 -0
- package/build/executor/shell.d.ts +89 -0
- package/build/executor/shell.d.ts.map +1 -0
- package/build/executor/shell.js +280 -0
- package/build/executor/shell.js.map +1 -0
- package/build/executor/spawn.d.ts +111 -0
- package/build/executor/spawn.d.ts.map +1 -0
- package/build/executor/spawn.js +314 -0
- package/build/executor/spawn.js.map +1 -0
- package/build/harness/index.d.ts +28 -0
- package/build/harness/index.d.ts.map +1 -0
- package/build/harness/index.js +83 -0
- package/build/harness/index.js.map +1 -0
- package/build/harness/templates.d.ts +30 -0
- package/build/harness/templates.d.ts.map +1 -0
- package/build/harness/templates.js +162 -0
- package/build/harness/templates.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +12 -0
- package/build/index.js.map +1 -0
- package/build/project/env.d.ts +108 -0
- package/build/project/env.d.ts.map +1 -0
- package/build/project/env.js +233 -0
- package/build/project/env.js.map +1 -0
- package/build/project/marker.d.ts +34 -0
- package/build/project/marker.d.ts.map +1 -0
- package/build/project/marker.js +54 -0
- package/build/project/marker.js.map +1 -0
- package/build/project/setup.d.ts +62 -0
- package/build/project/setup.d.ts.map +1 -0
- package/build/project/setup.js +190 -0
- package/build/project/setup.js.map +1 -0
- package/build/run/index.d.ts +75 -0
- package/build/run/index.d.ts.map +1 -0
- package/build/run/index.js +150 -0
- package/build/run/index.js.map +1 -0
- package/build/run/serve.d.ts +18 -0
- package/build/run/serve.d.ts.map +1 -0
- package/build/run/serve.js +148 -0
- package/build/run/serve.js.map +1 -0
- package/build/run/state.d.ts +75 -0
- package/build/run/state.d.ts.map +1 -0
- package/build/run/state.js +136 -0
- package/build/run/state.js.map +1 -0
- package/build/session/index.d.ts +17 -0
- package/build/session/index.d.ts.map +1 -0
- package/build/session/index.js +226 -0
- package/build/session/index.js.map +1 -0
- package/build/session/questions.d.ts +16 -0
- package/build/session/questions.d.ts.map +1 -0
- package/build/session/questions.js +17 -0
- package/build/session/questions.js.map +1 -0
- package/build/session/queue.d.ts +54 -0
- package/build/session/queue.d.ts.map +1 -0
- package/build/session/queue.js +102 -0
- package/build/session/queue.js.map +1 -0
- package/build/session/tasks.d.ts +15 -0
- package/build/session/tasks.d.ts.map +1 -0
- package/build/session/tasks.js +16 -0
- package/build/session/tasks.js.map +1 -0
- package/build/task/driver.d.ts +30 -0
- package/build/task/driver.d.ts.map +1 -0
- package/build/task/driver.js +120 -0
- package/build/task/driver.js.map +1 -0
- package/build/task/envelope.d.ts +35 -0
- package/build/task/envelope.d.ts.map +1 -0
- package/build/task/envelope.js +230 -0
- package/build/task/envelope.js.map +1 -0
- package/build/task/index.d.ts +4 -0
- package/build/task/index.d.ts.map +1 -0
- package/build/task/index.js +4 -0
- package/build/task/index.js.map +1 -0
- package/build/task/inquiry.d.ts +53 -0
- package/build/task/inquiry.d.ts.map +1 -0
- package/build/task/inquiry.js +232 -0
- package/build/task/inquiry.js.map +1 -0
- package/build/tools/catalogue.d.ts +17 -0
- package/build/tools/catalogue.d.ts.map +1 -0
- package/build/tools/catalogue.js +1169 -0
- package/build/tools/catalogue.js.map +1 -0
- package/build/tools/index.d.ts +7 -0
- package/build/tools/index.d.ts.map +1 -0
- package/build/tools/index.js +7 -0
- package/build/tools/index.js.map +1 -0
- package/build/tools/jobs.d.ts +12 -0
- package/build/tools/jobs.d.ts.map +1 -0
- package/build/tools/jobs.js +60 -0
- package/build/tools/jobs.js.map +1 -0
- package/build/tools/mcp.d.ts +43 -0
- package/build/tools/mcp.d.ts.map +1 -0
- package/build/tools/mcp.js +131 -0
- package/build/tools/mcp.js.map +1 -0
- package/build/tools/platform.d.ts +77 -0
- package/build/tools/platform.d.ts.map +1 -0
- package/build/tools/platform.js +316 -0
- package/build/tools/platform.js.map +1 -0
- package/build/tools/refusal.d.ts +66 -0
- package/build/tools/refusal.d.ts.map +1 -0
- package/build/tools/refusal.js +377 -0
- package/build/tools/refusal.js.map +1 -0
- package/build/tools/types.d.ts +83 -0
- package/build/tools/types.d.ts.map +1 -0
- package/build/tools/types.js +38 -0
- package/build/tools/types.js.map +1 -0
- package/build/types.d.ts +174 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +105 -0
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConnectTarget, ConversionDecision, ConversionStatus, ConvertibilityVerdict, decisionFor, MODEL_TIER_ROLES, OriginKind, STORY_BAND_MAX_USD, STORY_BAND_MIN_USD } from '@owlmeans/viable-common';
|
|
3
|
+
import { JOB_POLL_MAX_SEC, NEXT_QUESTION_WAIT_MS, NEXT_TASK_WAIT_MS } from '../consts.js';
|
|
4
|
+
import { describeHarness, installHarness } from '../harness/index.js';
|
|
5
|
+
import { envStatus } from '../project/env.js';
|
|
6
|
+
import { missingServices, readSetupReport, renderSetupGuide, setUserEnv } from '../project/setup.js';
|
|
7
|
+
import { localStatus, runLocal, stopLocal } from '../run/index.js';
|
|
8
|
+
import { parseTaskResult, renderTaskEnvelope } from '../task/envelope.js';
|
|
9
|
+
import { parseAnswer, renderQuestionEnvelope } from '../task/inquiry.js';
|
|
10
|
+
import { renderJob } from './jobs.js';
|
|
11
|
+
import { PLATFORM_CATALOGUE, renderPlatform } from './platform.js';
|
|
12
|
+
import { refusalMessage, refusalPhrase } from './refusal.js';
|
|
13
|
+
import { anyHost, cloudTarget, localTarget, performsModelTasks, sessionCapable, ToolHostKind, withExecutor } from './types.js';
|
|
14
|
+
const ok = (text, structured) => ({ text, structured });
|
|
15
|
+
const fail = (text) => ({ text, isError: true });
|
|
16
|
+
/**
|
|
17
|
+
* A conversion refusal is an ANSWER, so it comes back as one.
|
|
18
|
+
*
|
|
19
|
+
* Every conversion verb can be refused by a decision rather than by a failure — this origin is the
|
|
20
|
+
* project, that decision is not available from this stage, the balance will not cover it — and the
|
|
21
|
+
* refusal reaches this process as a marshalled `type|||marker|||stack` whose class is declared in
|
|
22
|
+
* a package the SDK does not depend on. Left to escape, the parent agent is handed that string and
|
|
23
|
+
* a stack trace from a machine it cannot reach; what it does with one is retry a call that can
|
|
24
|
+
* never succeed. {@link refusalPhrase} turns the marker into the sentence, and the result carries
|
|
25
|
+
* `isError` so the model still reads it as a refusal rather than as an answer.
|
|
26
|
+
*
|
|
27
|
+
* The MCP boundary phrases whatever escapes any other tool the same way; this wrapper is what puts
|
|
28
|
+
* the answer inside the tool, where the reason it was refused is still known.
|
|
29
|
+
*
|
|
30
|
+
* It also writes the LOG line that boundary would have written, and for the same reason: answering
|
|
31
|
+
* inside the tool is exactly what takes these five out of `registerCatalogue`'s catch, so without
|
|
32
|
+
* it a refused conversion is the one thing an operator can find nothing about in the connector log
|
|
33
|
+
* while every other tool is still recorded there. The marker is what is kept — that is what a
|
|
34
|
+
* person greps for — and the stack belongs to a machine they cannot reach.
|
|
35
|
+
*/
|
|
36
|
+
const answering = async (deps, name, run) => {
|
|
37
|
+
try {
|
|
38
|
+
return await run();
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
deps.log(`${name} refused: ${refusalMessage(e)}`);
|
|
42
|
+
return fail(refusalPhrase(e));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** The project a tool acts on: the one named, or the one the connector is attached to. */
|
|
46
|
+
/** The roles the platform maps onto each power class, so a caller sees what it is sizing. */
|
|
47
|
+
const rolesByTier = () => {
|
|
48
|
+
const byTier = {};
|
|
49
|
+
for (const [role, tier] of Object.entries(MODEL_TIER_ROLES)) {
|
|
50
|
+
byTier[tier] = [...(byTier[tier] ?? []), role];
|
|
51
|
+
}
|
|
52
|
+
return byTier;
|
|
53
|
+
};
|
|
54
|
+
const roleSummary = () => 'The platform asks for three power classes:\n'
|
|
55
|
+
+ Object.entries(rolesByTier())
|
|
56
|
+
.map(([tier, roles]) => ` ${tier} → ${roles.length} roles`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
/**
|
|
59
|
+
* How much of a drafted field one status answer carries.
|
|
60
|
+
*
|
|
61
|
+
* Generous, because reading the specification IS the confirm step — but bounded, since a tool
|
|
62
|
+
* answer shares one output budget with everything else the host is holding.
|
|
63
|
+
*/
|
|
64
|
+
const DRAFT_CAP = 8_000;
|
|
65
|
+
const projectOf = (args, deps) => {
|
|
66
|
+
const named = typeof args.projectId === 'string' ? args.projectId : null;
|
|
67
|
+
const project = named ?? deps.attached();
|
|
68
|
+
if (project == null) {
|
|
69
|
+
throw new Error('No project. Call create_project, or attach_project first.');
|
|
70
|
+
}
|
|
71
|
+
return project;
|
|
72
|
+
};
|
|
73
|
+
const jobResult = (job) => ok(renderJob(job), { job: job });
|
|
74
|
+
/**
|
|
75
|
+
* Attach a connector, where this host can hold one.
|
|
76
|
+
*
|
|
77
|
+
* Called by everything that starts platform work, because from that moment the platform may begin
|
|
78
|
+
* sending file writes and model tasks and nothing must arrive before somebody is listening. A host
|
|
79
|
+
* that answers one request and forgets is not that somebody: opening a session there would claim
|
|
80
|
+
* the project's single connector slot from the stdio connector that can actually serve it, so it
|
|
81
|
+
* opens none and the platform performs the run itself.
|
|
82
|
+
*/
|
|
83
|
+
const ensureSession = async (deps, projectId) => {
|
|
84
|
+
if (!sessionCapable(deps.host))
|
|
85
|
+
return;
|
|
86
|
+
// The session is filed against the project it will answer for, so a tool naming a project other
|
|
87
|
+
// than the attached one moves the connector BEFORE opening. Opening first would file the session
|
|
88
|
+
// against the previous project, and the platform would deliver this project's operations to
|
|
89
|
+
// nobody.
|
|
90
|
+
deps.attach(projectId);
|
|
91
|
+
await deps.session();
|
|
92
|
+
};
|
|
93
|
+
/** The project a tool acts on when it can also work without one. */
|
|
94
|
+
const projectOrNull = (args, deps) => typeof args.projectId === 'string' ? args.projectId : deps.attached();
|
|
95
|
+
/**
|
|
96
|
+
* The question a PARKED run is waiting on, when the connector's own queue holds none.
|
|
97
|
+
*
|
|
98
|
+
* A question is delivered as an operation and answered against it, and that is the fast path. But
|
|
99
|
+
* an operation expires: a run that asked while nobody was attached, or while this connector was
|
|
100
|
+
* being restarted, is left `Waiting` with a question no queue here has ever seen. Without this the
|
|
101
|
+
* only way back to it is the web application, and a parent polling the job would wait out the
|
|
102
|
+
* whole timeout on a question it could have carried in seconds.
|
|
103
|
+
*
|
|
104
|
+
* Best-effort on both reads. A project with no conversion answers an error, and failing the call
|
|
105
|
+
* that asked for a question because the lookup for it failed would be worse than answering "none".
|
|
106
|
+
*/
|
|
107
|
+
const parkedQuestion = async (deps, projectId, jobId) => {
|
|
108
|
+
try {
|
|
109
|
+
const status = await deps.api.convert.status(projectId);
|
|
110
|
+
if (status.pendingInquiry != null)
|
|
111
|
+
return status.pendingInquiry;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
deps.log(`no conversion question for ${projectId}: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
if (jobId == null)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const job = await deps.api.project.job(projectId, jobId);
|
|
120
|
+
return job.inquiry ?? null;
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
deps.log(`no question on job ${jobId}: ${e.message}`);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const questionResult = (inquiry, deps, note) => ok((note != null ? `${note}\n\n` : '') + renderQuestionEnvelope(inquiry, { harness: deps.host.harness }), { questionId: inquiry.id });
|
|
128
|
+
/** A cost estimate, always with the sentence that says what it is not. */
|
|
129
|
+
const renderEstimate = (estimate) => [
|
|
130
|
+
estimate.delegated
|
|
131
|
+
? `estimated cost of ${estimate.stage}: nothing — you perform this conversion's model calls`
|
|
132
|
+
: `estimated cost of ${estimate.stage}: $${estimate.usd.toFixed(2)}`
|
|
133
|
+
+ ` (${estimate.credits} credits)`,
|
|
134
|
+
' An estimate, not a price: it is computed from the size of the code before any of it is read.',
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* Whether this project has a conversion already, and one that has actually begun.
|
|
138
|
+
*
|
|
139
|
+
* A check answers a question a project already converting has moved past. Sending its parent to
|
|
140
|
+
* `convert_project` there points at the one tool that cannot help — the platform refuses a second
|
|
141
|
+
* Start over a live run — while the conversion sits at the decision or the question it is actually
|
|
142
|
+
* waiting on, which is what nobody notices.
|
|
143
|
+
*
|
|
144
|
+
* `Pending` and `Cancelled` are read as none: the record exists, nothing is under way, and
|
|
145
|
+
* starting one really is the next step.
|
|
146
|
+
*/
|
|
147
|
+
const conversionUnderWay = async (deps, projectId) => {
|
|
148
|
+
try {
|
|
149
|
+
const view = await deps.api.convert.status(projectId);
|
|
150
|
+
return view.status === ConversionStatus.Pending || view.status === ConversionStatus.Cancelled
|
|
151
|
+
? null
|
|
152
|
+
: view;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
// Best-effort, like every other second read here: a project with no conversion answers an
|
|
156
|
+
// error, and failing the check because the lookup for one failed is worse than answering
|
|
157
|
+
// without it.
|
|
158
|
+
deps.log(`no conversion for ${projectId}: ${e.message}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* How a check names the conversion the project already has.
|
|
164
|
+
*
|
|
165
|
+
* Derived from the STATUS, never from the mere presence of a record. {@link conversionUnderWay}
|
|
166
|
+
* filters `Pending` and `Cancelled` and nothing else, so a check over a finished or failed
|
|
167
|
+
* conversion read "already under way — stage implementation · done" one line above "next: nothing
|
|
168
|
+
* — this conversion is finished": one answer contradicting itself about whether anything is
|
|
169
|
+
* running, which leaves a parent polling a run that ended.
|
|
170
|
+
*/
|
|
171
|
+
const conversionLead = (view) => {
|
|
172
|
+
const where = `stage ${view.stage} · ${view.status}`;
|
|
173
|
+
switch (view.status) {
|
|
174
|
+
case ConversionStatus.Done:
|
|
175
|
+
return `conversion: finished — ${where}`;
|
|
176
|
+
case ConversionStatus.Failed:
|
|
177
|
+
return `conversion: stopped at ${where}`;
|
|
178
|
+
default:
|
|
179
|
+
return `conversion: already under way — ${where}`;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const renderCheck = (check, conversion) => {
|
|
183
|
+
const lines = [
|
|
184
|
+
`verdict: ${check.verdict}`
|
|
185
|
+
+ (check.reasons.length > 0 ? ` — ${check.reasons.join(', ')}` : ''),
|
|
186
|
+
`shape: ${check.shape}${check.architecture != null ? ` · ${check.architecture}` : ''}`
|
|
187
|
+
+ `${check.monorepo ? ' · monorepo' : ''}`,
|
|
188
|
+
...(check.stack != null
|
|
189
|
+
? [`stack: ${check.stack.label} (${check.stack.language}`
|
|
190
|
+
+ `${check.stack.framework != null ? `, ${check.stack.framework}` : ''})`]
|
|
191
|
+
: ['stack: not recognised']),
|
|
192
|
+
`${check.files} files · ${Math.round(check.bytes / 1024)} KB`
|
|
193
|
+
+ (check.bulk > 0 ? ` · ${check.bulk} bulk data file(s), sampled rather than read` : ''),
|
|
194
|
+
];
|
|
195
|
+
if (check.unlinked.length > 0) {
|
|
196
|
+
// Named rather than counted: each one is a decision somebody has to make, and the conversion
|
|
197
|
+
// will ask about it rather than guess.
|
|
198
|
+
lines.push(`nothing declares: ${check.unlinked.join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
if (check.estimate != null)
|
|
201
|
+
lines.push(...renderEstimate(check.estimate));
|
|
202
|
+
if (conversion != null) {
|
|
203
|
+
lines.push(`${conversionLead(conversion)} (conversion_status carries the whole picture)`);
|
|
204
|
+
}
|
|
205
|
+
lines.push(`next: ${check.verdict === ConvertibilityVerdict.Refused
|
|
206
|
+
// The verdict outranks a run, and deliberately: an origin nothing can convert has no next
|
|
207
|
+
// step whatever a record says about the attempt that found that out.
|
|
208
|
+
? 'nothing — this origin cannot be converted'
|
|
209
|
+
: conversion != null
|
|
210
|
+
? conversionNext(conversion)
|
|
211
|
+
: 'convert_project to start, then poll the job with wait_for'}`);
|
|
212
|
+
return lines.join('\n');
|
|
213
|
+
};
|
|
214
|
+
const renderConversion = (view) => {
|
|
215
|
+
const lines = [
|
|
216
|
+
`conversion of ${view.projectId} · stage ${view.stage} · ${view.status}`,
|
|
217
|
+
...(view.verdict != null ? [`verdict: ${view.verdict}`] : []),
|
|
218
|
+
...(view.stack != null
|
|
219
|
+
? [`origin stack: ${view.stack.label}`
|
|
220
|
+
+ `${view.architecture != null ? ` · ${view.architecture}` : ''}`]
|
|
221
|
+
: []),
|
|
222
|
+
...(view.targetStack != null ? [`rebuilt onto: ${view.targetStack.label}`] : []),
|
|
223
|
+
`origin sources: ${view.originState}`,
|
|
224
|
+
...(view.assumptions > 0
|
|
225
|
+
? [`${view.assumptions} question(s) answered by the platform itself —`
|
|
226
|
+
+ ' they are listed in docs/conversion/assumptions.md']
|
|
227
|
+
: []),
|
|
228
|
+
...(view.runId != null ? [`run ${view.runId}`] : []),
|
|
229
|
+
];
|
|
230
|
+
const estimate = view.estimates[view.estimates.length - 1];
|
|
231
|
+
if (estimate != null)
|
|
232
|
+
lines.push(...renderEstimate(estimate));
|
|
233
|
+
if (view.storyEstimate != null) {
|
|
234
|
+
lines.push(`implementing the stories: $${view.storyEstimate.minUsd}–$${view.storyEstimate.maxUsd}`
|
|
235
|
+
+ ` (${view.storyEstimate.minCredits}–${view.storyEstimate.maxCredits} credits)`, ` A band, not a price: a story costs what it turns out to need, usually`
|
|
236
|
+
+ ` $${STORY_BAND_MIN_USD}–$${STORY_BAND_MAX_USD} each.`);
|
|
237
|
+
}
|
|
238
|
+
// Phrased, exactly like a thrown one: the platform writes a failed stage's cause here with
|
|
239
|
+
// `describeFailure`, which for a refusal is the marker verbatim — so a conversion stopped by a
|
|
240
|
+
// declined relocation answered this tool with `viable-agent-common:conversion:relocate-declined`
|
|
241
|
+
// while `wait_for` on the very same run read out the sentence. One refusal, two readings.
|
|
242
|
+
if (view.lastError != null && view.lastError !== '') {
|
|
243
|
+
lines.push(`error: ${refusalPhrase(view.lastError)}`);
|
|
244
|
+
}
|
|
245
|
+
lines.push(`next: ${conversionNext(view)}`);
|
|
246
|
+
return lines.join('\n');
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* What to call after reading a conversion.
|
|
250
|
+
*
|
|
251
|
+
* Derived from the STATUS first and the stage second, because the two say different things: a
|
|
252
|
+
* stage says how far the conversion has got, and only the status says whether the platform is
|
|
253
|
+
* working, waiting for a person, or waiting for a decision that is the user's.
|
|
254
|
+
*/
|
|
255
|
+
const conversionNext = (view) => {
|
|
256
|
+
switch (view.status) {
|
|
257
|
+
case ConversionStatus.Running:
|
|
258
|
+
return 'wait_for on the job, or conversion_status again';
|
|
259
|
+
case ConversionStatus.Waiting:
|
|
260
|
+
return 'next_question — this conversion needs a decision from the person you work for';
|
|
261
|
+
case ConversionStatus.Awaiting:
|
|
262
|
+
return `proceed_conversion { "decision": "${decisionFor(view.stage)}" } to go on, or`
|
|
263
|
+
+ ` { "decision": "${ConversionDecision.Leave}" } to keep what it has produced`;
|
|
264
|
+
case ConversionStatus.Failed:
|
|
265
|
+
return `proceed_conversion { "decision": "${ConversionDecision.Retry}" } once the cause is addressed`;
|
|
266
|
+
case ConversionStatus.Done:
|
|
267
|
+
return 'nothing — this conversion is finished. purge_origin deletes the original sources.';
|
|
268
|
+
case ConversionStatus.Cancelled:
|
|
269
|
+
return 'nothing — this conversion was cancelled';
|
|
270
|
+
default:
|
|
271
|
+
return 'convert_project to start it';
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Everything a parent agent can ask the platform to do.
|
|
276
|
+
*
|
|
277
|
+
* Two rules shape the whole list. Every tool answers inside the strictest host's per-tool ceiling,
|
|
278
|
+
* so anything that takes minutes returns a JOB at once and is polled — a coding agent that blocks
|
|
279
|
+
* for twenty minutes is reported to its user as a hung server. And every tool that cannot work in
|
|
280
|
+
* a given mode is HIDDEN rather than failing, so the catalogue a parent reads is exactly the set
|
|
281
|
+
* of things that work for it.
|
|
282
|
+
*/
|
|
283
|
+
export const catalogue = [
|
|
284
|
+
{
|
|
285
|
+
name: 'describe_platform',
|
|
286
|
+
title: 'What this platform can build, and what you can drive from here',
|
|
287
|
+
description: 'Everything the platform runs — building an application from a description, implementing'
|
|
288
|
+
+ ' user stories, converting an application you already have — and which of it this session'
|
|
289
|
+
+ ' can start. Read it before deciding how to approach a request. Needs no project and'
|
|
290
|
+
+ ' makes no network call.',
|
|
291
|
+
input: {},
|
|
292
|
+
availability: anyHost,
|
|
293
|
+
run: async (_args, deps) => ok(renderPlatform(PLATFORM_CATALOGUE, deps.host)),
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'describe_capabilities',
|
|
297
|
+
title: 'Report what your models can do',
|
|
298
|
+
description: 'Tell the platform which of your models it may use, so it can size each call. Call this once,'
|
|
299
|
+
+ ' before creating or developing anything. Returns the mapping it will use.',
|
|
300
|
+
input: {
|
|
301
|
+
strong: z.string().optional().describe('Your most capable model, e.g. the one you plan with'),
|
|
302
|
+
standard: z.string().optional().describe('Your everyday model'),
|
|
303
|
+
cheap: z.string().optional().describe('Your fastest/cheapest model'),
|
|
304
|
+
subagents: z.boolean().optional().describe('Can you run a task in an isolated subagent?'),
|
|
305
|
+
effortControl: z.boolean().optional().describe('Can you ask for low reasoning effort?'),
|
|
306
|
+
},
|
|
307
|
+
availability: anyHost,
|
|
308
|
+
run: async (args, deps) => {
|
|
309
|
+
const tiers = {};
|
|
310
|
+
for (const tier of ['strong', 'standard', 'cheap']) {
|
|
311
|
+
const model = args[tier];
|
|
312
|
+
if (typeof model === 'string' && model !== '')
|
|
313
|
+
tiers[tier] = model;
|
|
314
|
+
}
|
|
315
|
+
const subagents = typeof args.subagents === 'boolean' ? args.subagents : null;
|
|
316
|
+
const effortControl = typeof args.effortControl === 'boolean' ? args.effortControl : null;
|
|
317
|
+
deps.log(`capabilities: ${JSON.stringify({ tiers, subagents, effortControl })}`);
|
|
318
|
+
// Answered honestly rather than with "Recorded." for anything. The arguments are all
|
|
319
|
+
// optional and the shape is flat, so a caller that nests them under a `tiers` key — a
|
|
320
|
+
// reasonable guess, and one a real agent made — supplies nothing at all and would otherwise
|
|
321
|
+
// be told its call succeeded while the platform recorded no model of theirs whatsoever.
|
|
322
|
+
if (Object.keys(tiers).length < 1) {
|
|
323
|
+
return ok('No model was recorded: this tool takes `strong`, `standard` and `cheap` as TOP-LEVEL'
|
|
324
|
+
+ ' string arguments, not nested under another key. The platform will size every call'
|
|
325
|
+
+ ' with its own defaults until you send them.\n\n'
|
|
326
|
+
+ roleSummary(), { tiers, roles: rolesByTier() });
|
|
327
|
+
}
|
|
328
|
+
const byTier = rolesByTier();
|
|
329
|
+
return ok('Recorded. The platform asks for three power classes:\n'
|
|
330
|
+
+ Object.entries(byTier)
|
|
331
|
+
.map(([tier, roles]) => ` ${tier} → ${tiers[tier] ?? '(your default)'} · ${roles.length} roles`)
|
|
332
|
+
.join('\n')
|
|
333
|
+
+ `\n\nsubagents: ${subagents == null ? 'not stated' : subagents ? 'yes' : 'no'}`
|
|
334
|
+
+ ` · low reasoning effort: ${effortControl == null ? 'not stated' : effortControl ? 'yes' : 'no'}`
|
|
335
|
+
// Said differently in the two cases, because the answer to "which calls are mine" differs:
|
|
336
|
+
// a delegated session performs all of them, an ordinary one performs a conversion's. Both
|
|
337
|
+
// collect them the same way, so both are told to call next_task.
|
|
338
|
+
+ (performsModelTasks(deps.host)
|
|
339
|
+
? '\n\nThis session runs the platform\'s model calls on YOUR side. Whenever a job reports'
|
|
340
|
+
+ ' "blocked on: model-task", call next_task.'
|
|
341
|
+
: sessionCapable(deps.host)
|
|
342
|
+
? '\n\nThe platform performs its own model calls for stories and free flight. A'
|
|
343
|
+
+ ' CONVERSION\'s calls are yours by default. Whenever a job reports "blocked on:'
|
|
344
|
+
+ ' model-task", call next_task.'
|
|
345
|
+
: ''), { tiers, subagents, effortControl, roles: byTier });
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: 'describe_harness',
|
|
350
|
+
title: 'What the harness files would say',
|
|
351
|
+
description: 'Preview the files install_harness would write for a coding agent, so you can read them'
|
|
352
|
+
+ ' before anything is changed on disk.',
|
|
353
|
+
input: { harness: z.string().optional().describe('claude-code | codex | copilot | opencode') },
|
|
354
|
+
availability: anyHost,
|
|
355
|
+
run: async (args, deps) => {
|
|
356
|
+
const harness = args.harness ?? deps.host.harness;
|
|
357
|
+
const files = describeHarness(harness);
|
|
358
|
+
return ok(`${harness} — ${files.length} files:\n`
|
|
359
|
+
+ files.map(file => `\n--- ${file.path} ---\n${file.content}`).join('\n'), { harness, files: files.map(file => file.path) });
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'install_harness',
|
|
364
|
+
title: 'Set this agent up to work with the platform',
|
|
365
|
+
description: 'Write the instruction and subagent files this coding agent needs, into the project directory.'
|
|
366
|
+
+ ' Idempotent, and it never writes your token — only a reference to the environment variable.',
|
|
367
|
+
input: {
|
|
368
|
+
dir: z.string().optional().describe('Where to write. Defaults to the project directory.'),
|
|
369
|
+
harness: z.string().optional(),
|
|
370
|
+
mcpConfig: z.boolean().optional().describe('Also write the MCP server entry'),
|
|
371
|
+
},
|
|
372
|
+
availability: withExecutor,
|
|
373
|
+
run: async (args, deps) => {
|
|
374
|
+
const dir = args.dir ?? deps.dir;
|
|
375
|
+
if (dir == null)
|
|
376
|
+
return fail('No directory to write to.');
|
|
377
|
+
const harness = args.harness ?? deps.host.harness;
|
|
378
|
+
const result = await installHarness(dir, harness, {
|
|
379
|
+
mcpConfig: args.mcpConfig === true,
|
|
380
|
+
});
|
|
381
|
+
return ok(`Wrote ${result.written.length} file(s) for ${harness}:\n`
|
|
382
|
+
+ result.written.map(path => ` ${path}`).join('\n')
|
|
383
|
+
+ (result.skipped.length > 0 ? `\nUnchanged: ${result.skipped.join(', ')}` : ''), result);
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: 'session_status',
|
|
388
|
+
title: 'What this connector is doing',
|
|
389
|
+
description: 'The mode, the attached project, and what the connector has handled so far.',
|
|
390
|
+
input: {},
|
|
391
|
+
availability: anyHost,
|
|
392
|
+
run: async (_args, deps) => {
|
|
393
|
+
const session = deps.currentSession();
|
|
394
|
+
const lines = [
|
|
395
|
+
`target: ${deps.host.target} · llm: ${deps.host.llm} · harness: ${deps.host.harness}`,
|
|
396
|
+
`project: ${deps.attached() ?? '(none attached)'}`,
|
|
397
|
+
...(deps.dir != null ? [`directory: ${deps.dir}`] : []),
|
|
398
|
+
];
|
|
399
|
+
if (session == null) {
|
|
400
|
+
lines.push(sessionCapable(deps.host)
|
|
401
|
+
? 'session: not opened yet — it opens with the first operation'
|
|
402
|
+
: 'session: none — this host holds none, and the platform performs the work itself');
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const stats = session.stats;
|
|
406
|
+
lines.push(`session ${session.session.id} · ${stats.transport}`, `operations: ${stats.opsDone} done, ${stats.opsFailed} failed`, `model tasks: ${stats.tasksDelivered} delivered, ${stats.tasksSubmitted} answered,`
|
|
407
|
+
+ ` ${session.pendingTasks()} waiting`, `questions: ${stats.questionsDelivered} asked, ${stats.questionsAnswered} answered,`
|
|
408
|
+
+ ` ${session.pendingQuestions()} waiting`);
|
|
409
|
+
}
|
|
410
|
+
return ok(lines.join('\n'));
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: 'wait_for',
|
|
415
|
+
title: 'Wait for a job',
|
|
416
|
+
description: `Poll one job for up to ${JOB_POLL_MAX_SEC} seconds. Returns as soon as it finishes or`
|
|
417
|
+
+ ' becomes blocked; call again while it is still running.',
|
|
418
|
+
input: {
|
|
419
|
+
jobId: z.string(),
|
|
420
|
+
projectId: z.string().optional(),
|
|
421
|
+
maxWaitSec: z.number().int().min(0).max(JOB_POLL_MAX_SEC).optional(),
|
|
422
|
+
},
|
|
423
|
+
availability: anyHost,
|
|
424
|
+
run: async (args, deps) => {
|
|
425
|
+
const project = projectOf(args, deps);
|
|
426
|
+
const wait = Math.min(args.maxWaitSec ?? JOB_POLL_MAX_SEC, JOB_POLL_MAX_SEC);
|
|
427
|
+
const job = await deps.api.project.job(project, args.jobId, wait);
|
|
428
|
+
return jobResult(job);
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'create_project',
|
|
433
|
+
title: 'Start a project from a description',
|
|
434
|
+
description: 'Describe the application in a sentence or two. The platform writes a specification, names it'
|
|
435
|
+
+ ' and drafts a vision. Returns a job — poll it with wait_for, then read the draft and call'
|
|
436
|
+
+ ' confirm_project.',
|
|
437
|
+
input: { prompt: z.string().min(1) },
|
|
438
|
+
availability: anyHost,
|
|
439
|
+
run: async (args, deps) => {
|
|
440
|
+
const job = await deps.api.project.create(args.prompt, deps.host.target === ConnectTarget.Local ? ConnectTarget.Local : ConnectTarget.Cloud);
|
|
441
|
+
deps.attach(job.projectId);
|
|
442
|
+
return jobResult(job);
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: 'confirm_project',
|
|
447
|
+
title: 'Accept the specification and build the application',
|
|
448
|
+
description: 'Confirm the drafted project, optionally editing what the analysis produced. This is what'
|
|
449
|
+
+ ' starts the build: the template lands, dependencies install, the whole application is'
|
|
450
|
+
+ ' drawn. Returns a job — it takes several minutes.',
|
|
451
|
+
input: {
|
|
452
|
+
projectId: z.string().optional(),
|
|
453
|
+
name: z.string().optional(),
|
|
454
|
+
description: z.string().optional(),
|
|
455
|
+
specification: z.string().optional(),
|
|
456
|
+
vision: z.string().optional(),
|
|
457
|
+
},
|
|
458
|
+
availability: anyHost,
|
|
459
|
+
run: async (args, deps) => {
|
|
460
|
+
const project = projectOf(args, deps);
|
|
461
|
+
// Attached BEFORE the call that starts the build, not after it. The platform begins
|
|
462
|
+
// sending file writes as soon as this returns, and an operation dispatched at a project
|
|
463
|
+
// whose connector has not filed a session yet is not queued for later — a local target
|
|
464
|
+
// answers it with `ConnectSessionGone` and the step fails.
|
|
465
|
+
await ensureSession(deps, project);
|
|
466
|
+
const job = await deps.api.project.confirm(project, {
|
|
467
|
+
...(typeof args.name === 'string' ? { name: args.name } : {}),
|
|
468
|
+
...(typeof args.description === 'string' ? { description: args.description } : {}),
|
|
469
|
+
...(typeof args.specification === 'string' ? { specification: args.specification } : {}),
|
|
470
|
+
...(typeof args.vision === 'string' ? { vision: args.vision } : {}),
|
|
471
|
+
});
|
|
472
|
+
return jobResult(job);
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: 'project_status',
|
|
477
|
+
title: 'Where the project stands',
|
|
478
|
+
description: 'The project, its slot, whether the agent is busy, and the last run.',
|
|
479
|
+
input: { projectId: z.string().optional() },
|
|
480
|
+
availability: anyHost,
|
|
481
|
+
run: async (args, deps) => {
|
|
482
|
+
const status = await deps.api.project.status(projectOf(args, deps));
|
|
483
|
+
const lines = [
|
|
484
|
+
`${status.project.name} (${status.project.alias}) · ${status.project.id}`,
|
|
485
|
+
status.slot != null
|
|
486
|
+
? `slot: ${status.slot.kind} · ${status.slot.status}`
|
|
487
|
+
+ `${status.slot.initialized === true ? ' · initialized' : ' · not initialized'}`
|
|
488
|
+
+ `${status.slot.host != null ? ` · ${status.slot.host}` : ''}`
|
|
489
|
+
: 'slot: none',
|
|
490
|
+
`agent: ${status.agent.locked ? `busy (${status.agent.task ?? 'working'})` : 'idle'}`,
|
|
491
|
+
];
|
|
492
|
+
if (status.run != null) {
|
|
493
|
+
lines.push(`run ${status.run.runId} · ${status.run.status}`
|
|
494
|
+
+ `${status.run.step != null ? ` · at ${status.run.step}` : ''}`);
|
|
495
|
+
}
|
|
496
|
+
// The drafted text, because `confirm_project` accepts edits to exactly these fields and
|
|
497
|
+
// there is nowhere else to read them: before initialization they exist only on the record,
|
|
498
|
+
// and `docs/project.md` is not written until the run that confirm starts.
|
|
499
|
+
for (const [label, value] of [
|
|
500
|
+
['description', status.project.description],
|
|
501
|
+
['specification', status.project.specification],
|
|
502
|
+
['vision', status.project.vision],
|
|
503
|
+
]) {
|
|
504
|
+
if (value != null && value.trim() !== '') {
|
|
505
|
+
lines.push('', `--- ${label} ---`, value.slice(0, DRAFT_CAP));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// `slot.lastError` is the channel the platform stores a content refusal, a reserved name or
|
|
509
|
+
// a legacy-layout verdict on, with no class left on it — so it is phrased here for the same
|
|
510
|
+
// reason the manager phrases it, and `backendWarning` carries an integrity verdict the same
|
|
511
|
+
// way. All three go through one helper because only the TEXT says which it is: anything that
|
|
512
|
+
// does not read as a marker — the build diagnostics somebody asked for, stack-shaped lines
|
|
513
|
+
// included — comes back exactly as it stands.
|
|
514
|
+
for (const warning of [status.slot?.lastError, status.slot?.buildWarning, status.slot?.backendWarning]) {
|
|
515
|
+
if (warning != null && warning !== '')
|
|
516
|
+
lines.push(`warning: ${refusalPhrase(warning)}`);
|
|
517
|
+
}
|
|
518
|
+
return ok(lines.join('\n'), status);
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: 'list_projects',
|
|
523
|
+
title: 'The projects on this account',
|
|
524
|
+
description: 'Every project this access token can reach, newest first, with the id, slug and status'
|
|
525
|
+
+ ' each of the other project tools takes.',
|
|
526
|
+
input: {},
|
|
527
|
+
availability: anyHost,
|
|
528
|
+
run: async (_args, deps) => {
|
|
529
|
+
const projects = await deps.api.project.list();
|
|
530
|
+
return ok(projects.length < 1
|
|
531
|
+
? 'No projects yet. create_project starts one.'
|
|
532
|
+
: projects.map(project => `${project.id} · ${project.name} (${project.alias})`).join('\n'), { projects: projects });
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: 'attach_project',
|
|
537
|
+
title: 'Work on an existing project',
|
|
538
|
+
description: 'Point this connector at a project that already exists — by id, by its slug, or (for a local'
|
|
539
|
+
+ ' project) by reading the marker in its directory.',
|
|
540
|
+
input: {
|
|
541
|
+
projectId: z.string().optional(),
|
|
542
|
+
slug: z.string().optional(),
|
|
543
|
+
},
|
|
544
|
+
availability: anyHost,
|
|
545
|
+
run: async (args, deps) => {
|
|
546
|
+
const status = await deps.api.project.attach({
|
|
547
|
+
...(typeof args.projectId === 'string' ? { projectId: args.projectId } : {}),
|
|
548
|
+
...(typeof args.slug === 'string' ? { slug: args.slug } : {}),
|
|
549
|
+
});
|
|
550
|
+
deps.attach(status.project.id);
|
|
551
|
+
return ok(`Attached to ${status.project.name} (${status.project.id}).`, status);
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: 'reinitialize_project',
|
|
556
|
+
title: 'Rebuild the application from the template',
|
|
557
|
+
description: 'Wipe the generated sources and lay the template down again. The user stories are KEPT and'
|
|
558
|
+
+ ' reset to planned; your own configuration and git history survive.',
|
|
559
|
+
input: { projectId: z.string().optional() },
|
|
560
|
+
availability: anyHost,
|
|
561
|
+
run: async (args, deps) => {
|
|
562
|
+
const project = projectOf(args, deps);
|
|
563
|
+
await ensureSession(deps, project);
|
|
564
|
+
const job = await deps.api.project.reinit(project);
|
|
565
|
+
return jobResult(job);
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: 'list_stories',
|
|
570
|
+
title: 'The user stories of a project',
|
|
571
|
+
description: 'One page of the project\'s stories, with their status.',
|
|
572
|
+
input: {
|
|
573
|
+
projectId: z.string().optional(),
|
|
574
|
+
page: z.number().int().min(0).optional(),
|
|
575
|
+
size: z.number().int().min(1).max(100).optional(),
|
|
576
|
+
status: z.string().optional(),
|
|
577
|
+
area: z.string().optional(),
|
|
578
|
+
},
|
|
579
|
+
availability: anyHost,
|
|
580
|
+
run: async (args, deps) => {
|
|
581
|
+
const list = await deps.api.story.list(projectOf(args, deps), {
|
|
582
|
+
...(typeof args.page === 'number' ? { page: args.page } : {}),
|
|
583
|
+
...(typeof args.size === 'number' ? { size: args.size } : {}),
|
|
584
|
+
...(typeof args.status === 'string' ? { status: args.status } : {}),
|
|
585
|
+
...(typeof args.area === 'string' ? { area: args.area } : {}),
|
|
586
|
+
});
|
|
587
|
+
return ok(renderStories(list.items, list.page, list.total), list);
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'search_stories',
|
|
592
|
+
title: 'Find a story',
|
|
593
|
+
description: 'The user stories of a project whose code, title or text match a search term. Use it instead'
|
|
594
|
+
+ ' of paging through list_stories when you know what you are looking for.',
|
|
595
|
+
input: { q: z.string().min(1), projectId: z.string().optional() },
|
|
596
|
+
availability: anyHost,
|
|
597
|
+
run: async (args, deps) => {
|
|
598
|
+
const list = await deps.api.story.list(projectOf(args, deps), { q: args.q });
|
|
599
|
+
return ok(renderStories(list.items, list.page, list.total), list);
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: 'create_story',
|
|
604
|
+
title: 'Add a user story',
|
|
605
|
+
description: 'Add a story in your own words. The platform rewrites it in the form its pipeline needs and'
|
|
606
|
+
+ ' decides which area of the application it belongs to.',
|
|
607
|
+
input: { story: z.string().min(1), projectId: z.string().optional() },
|
|
608
|
+
availability: anyHost,
|
|
609
|
+
run: async (args, deps) => {
|
|
610
|
+
const created = await deps.api.story.create(projectOf(args, deps), args.story);
|
|
611
|
+
return ok('Story created.', created);
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: 'update_story',
|
|
616
|
+
title: 'Reword a user story',
|
|
617
|
+
description: 'Change what a story says. Its status is not changed — develop_story does that.',
|
|
618
|
+
input: { storyId: z.string(), story: z.string().min(1), projectId: z.string().optional() },
|
|
619
|
+
availability: anyHost,
|
|
620
|
+
run: async (args, deps) => {
|
|
621
|
+
const updated = await deps.api.story.update(projectOf(args, deps), args.storyId, args.story);
|
|
622
|
+
return ok('Story updated.', updated);
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'delete_story',
|
|
627
|
+
title: 'Remove a user story',
|
|
628
|
+
description: 'Delete a story that has not been implemented. Its placeholder screens are retired.',
|
|
629
|
+
input: { storyId: z.string(), projectId: z.string().optional() },
|
|
630
|
+
availability: anyHost,
|
|
631
|
+
run: async (args, deps) => {
|
|
632
|
+
await deps.api.story.remove(projectOf(args, deps), args.storyId);
|
|
633
|
+
return ok('Story deleted.');
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'develop_story',
|
|
638
|
+
title: 'Implement a user story',
|
|
639
|
+
description: 'Ask the platform to design and build one story: its screens, its data, its endpoints, its'
|
|
640
|
+
+ ' navigation. This is the main event and takes many minutes. Returns a job.',
|
|
641
|
+
input: { storyId: z.string(), projectId: z.string().optional() },
|
|
642
|
+
availability: anyHost,
|
|
643
|
+
run: async (args, deps) => {
|
|
644
|
+
const project = projectOf(args, deps);
|
|
645
|
+
await ensureSession(deps, project);
|
|
646
|
+
const job = await deps.api.story.develop(project, args.storyId);
|
|
647
|
+
return jobResult(job);
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: 'story_status',
|
|
652
|
+
title: 'What happened to a story',
|
|
653
|
+
description: 'One story: its status, and the warning explaining a failure.',
|
|
654
|
+
input: { storyId: z.string(), projectId: z.string().optional() },
|
|
655
|
+
availability: anyHost,
|
|
656
|
+
run: async (args, deps) => {
|
|
657
|
+
const story = await deps.api.story.get(projectOf(args, deps), args.storyId);
|
|
658
|
+
return ok(`${story.code ?? story.id} · ${story.status}`
|
|
659
|
+
+ (story.warning != null ? `\nwarning: ${String(story.warning)}` : ''), story);
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: 'modify_project',
|
|
664
|
+
title: 'Ask the agent for an open-ended change',
|
|
665
|
+
description: 'Describe a change in words and let the platform\'s own coding agent make it. For anything'
|
|
666
|
+
+ ' that is not a user story: a rename, a fix, a styling change. Returns a job.',
|
|
667
|
+
input: { prompt: z.string().min(1), projectId: z.string().optional() },
|
|
668
|
+
availability: anyHost,
|
|
669
|
+
run: async (args, deps) => {
|
|
670
|
+
const project = projectOf(args, deps);
|
|
671
|
+
await ensureSession(deps, project);
|
|
672
|
+
const job = await deps.api.project.modify(project, args.prompt);
|
|
673
|
+
return jobResult(job);
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: 'pipeline_status',
|
|
678
|
+
title: 'Where a run stopped',
|
|
679
|
+
description: 'The steps a run completed and the one it stopped at.',
|
|
680
|
+
input: { runId: z.string(), projectId: z.string().optional() },
|
|
681
|
+
availability: anyHost,
|
|
682
|
+
run: async (args, deps) => {
|
|
683
|
+
const state = await deps.api.pipeline.state(projectOf(args, deps), args.runId);
|
|
684
|
+
return ok(`${String(state.pipeline)} · ${String(state.status)}`
|
|
685
|
+
+ (state.step != null ? ` · stopped at ${String(state.step)}` : '')
|
|
686
|
+
// A run stops on the same refusals a call is thrown, and the row keeps the cause as text.
|
|
687
|
+
+ (state.error != null ? `\nerror: ${refusalPhrase(String(state.error))}` : ''), state);
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: 'resume_pipeline',
|
|
692
|
+
title: 'Continue a run that stopped',
|
|
693
|
+
description: 'Pick a crashed or interrupted run up where it stopped, rather than starting over. Use it'
|
|
694
|
+
+ ' after fixing whatever it failed on — a missing database, a connector that dropped.',
|
|
695
|
+
input: {
|
|
696
|
+
runId: z.string(),
|
|
697
|
+
projectId: z.string().optional(),
|
|
698
|
+
from: z.string().optional().describe('Re-enter this step, and everything after it'),
|
|
699
|
+
},
|
|
700
|
+
availability: anyHost,
|
|
701
|
+
run: async (args, deps) => {
|
|
702
|
+
const project = projectOf(args, deps);
|
|
703
|
+
await ensureSession(deps, project);
|
|
704
|
+
const job = await deps.api.pipeline.resume(project, args.runId, {
|
|
705
|
+
...(typeof args.from === 'string' ? { from: args.from } : {}),
|
|
706
|
+
});
|
|
707
|
+
return jobResult(job);
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: 'next_task',
|
|
712
|
+
title: 'Get the next model call to perform',
|
|
713
|
+
description: 'The platform hands you model calls to perform: a conversion\'s by default, and everything'
|
|
714
|
+
+ ' else when this session runs in the delegated mode. Returns one task with instructions for'
|
|
715
|
+
+ ' running it in a clean subagent, or says there is nothing yet. Call it whenever a job'
|
|
716
|
+
+ ' reports "blocked on: model-task", and keep calling until it says none.',
|
|
717
|
+
input: {
|
|
718
|
+
maxWaitSec: z.number().int().min(0).max(45).optional(),
|
|
719
|
+
taskId: z.string().optional()
|
|
720
|
+
.describe('Re-read a task you were already given, if you lost its text.'),
|
|
721
|
+
},
|
|
722
|
+
// Not `performsModelTasks`: a conversion's model calls are the parent's by default whatever
|
|
723
|
+
// the account setting says, so a session billed to the platform must still be able to collect
|
|
724
|
+
// one. What they do need is a host that STAYS — a task is handed out once and answered
|
|
725
|
+
// minutes later.
|
|
726
|
+
availability: sessionCapable,
|
|
727
|
+
run: async (args, deps) => {
|
|
728
|
+
const session = await deps.session();
|
|
729
|
+
// Asking for one by id re-reads it rather than taking a new one. A task is handed out once
|
|
730
|
+
// and the platform waits on it for up to 45 minutes, so a parent that lost the envelope —
|
|
731
|
+
// a compacted conversation, a subagent that died before answering — otherwise has no way
|
|
732
|
+
// back to it and the job blocks until the deadline for no reason.
|
|
733
|
+
if (typeof args.taskId === 'string') {
|
|
734
|
+
const known = session.taskById(args.taskId);
|
|
735
|
+
if (known == null) {
|
|
736
|
+
return fail(`No task ${args.taskId} is outstanding. Call next_task with no id.`);
|
|
737
|
+
}
|
|
738
|
+
return ok(renderTaskEnvelope(known, { harness: deps.host.harness }), { taskId: known.id });
|
|
739
|
+
}
|
|
740
|
+
const wait = Math.min((args.maxWaitSec ?? 30) * 1000, NEXT_TASK_WAIT_MS);
|
|
741
|
+
const task = await session.nextTask(wait);
|
|
742
|
+
if (task == null) {
|
|
743
|
+
const outstanding = session.outstandingTasks();
|
|
744
|
+
if (outstanding.length > 0) {
|
|
745
|
+
// Named rather than re-handed: a parent whose subagent is still working would otherwise
|
|
746
|
+
// be given the same task again and run it twice.
|
|
747
|
+
return ok(`No new task. ${outstanding.length} already handed to you and still unanswered:`
|
|
748
|
+
+ ` ${outstanding.map(one => one.id).join(', ')}. Submit the answer with`
|
|
749
|
+
+ ' submit_task_result, or call next_task with that taskId to read it again.');
|
|
750
|
+
}
|
|
751
|
+
return ok('No task right now. If a job is still running, call wait_for; if it reported'
|
|
752
|
+
+ ' "blocked on: model-task", call next_task again.');
|
|
753
|
+
}
|
|
754
|
+
return ok(renderTaskEnvelope(task, { harness: deps.host.harness }), { taskId: task.id });
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
name: 'submit_task_result',
|
|
759
|
+
title: 'Hand back what the subagent produced',
|
|
760
|
+
description: 'Send the subagent\'s answer, verbatim. If the answer is not the shape the task asked for,'
|
|
761
|
+
+ ' this says so and the task stays open for you to retry.',
|
|
762
|
+
input: {
|
|
763
|
+
taskId: z.string(),
|
|
764
|
+
result: z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]),
|
|
765
|
+
},
|
|
766
|
+
availability: sessionCapable,
|
|
767
|
+
run: async (args, deps) => {
|
|
768
|
+
const session = await deps.session();
|
|
769
|
+
const pending = session;
|
|
770
|
+
const task = pending.taskById?.(args.taskId);
|
|
771
|
+
if (task == null) {
|
|
772
|
+
return fail(`No task ${String(args.taskId)} is waiting. Call next_task for the current one.`);
|
|
773
|
+
}
|
|
774
|
+
const { result, problem } = parseTaskResult(task, args.result);
|
|
775
|
+
if (problem != null) {
|
|
776
|
+
// Refused HERE, with the subagent's context still open — a malformed answer that reached
|
|
777
|
+
// the platform would cost a whole new task, a new subagent and another wait.
|
|
778
|
+
return fail(problem);
|
|
779
|
+
}
|
|
780
|
+
await session.submitTask(result);
|
|
781
|
+
const next = session.pendingTasks();
|
|
782
|
+
return ok(next > 0
|
|
783
|
+
? `Accepted. ${next} more task(s) waiting — call next_task.`
|
|
784
|
+
: 'Accepted. Call wait_for on the job, or next_task if it is still blocked.');
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: 'next_question',
|
|
789
|
+
title: 'Get the question the platform is asking',
|
|
790
|
+
description: 'The platform sometimes needs a decision only the person you are working for can make.'
|
|
791
|
+
+ ' Returns one question to put to them, or says there is none. Call it whenever a job'
|
|
792
|
+
+ ' reports "blocked on: question".',
|
|
793
|
+
input: {
|
|
794
|
+
maxWaitSec: z.number().int().min(0).max(45).optional(),
|
|
795
|
+
questionId: z.string().optional().describe('Re-read a question you were already given.'),
|
|
796
|
+
jobId: z.string().optional()
|
|
797
|
+
.describe('The job that reported "blocked on: question", if you have its id.'),
|
|
798
|
+
projectId: z.string().optional(),
|
|
799
|
+
},
|
|
800
|
+
// Not `performsModelTasks`: a question has nothing to do with who performs the model calls,
|
|
801
|
+
// and a session billed to the platform must still be askable. What it does need is a host
|
|
802
|
+
// that STAYS — a question is delivered to a connector and answered minutes later.
|
|
803
|
+
availability: sessionCapable,
|
|
804
|
+
run: async (args, deps) => {
|
|
805
|
+
const session = await deps.session();
|
|
806
|
+
const project = projectOrNull(args, deps);
|
|
807
|
+
const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
|
|
808
|
+
// Asking for one by id re-reads it rather than taking a new one — the same reason
|
|
809
|
+
// `next_task` does: a parent that lost the envelope has no other way back to it.
|
|
810
|
+
if (typeof args.questionId === 'string') {
|
|
811
|
+
const known = session.questionById(args.questionId)
|
|
812
|
+
?? (project != null ? await parkedQuestion(deps, project, jobId) : null);
|
|
813
|
+
if (known == null || known.id !== args.questionId) {
|
|
814
|
+
return fail(`No question ${args.questionId} is waiting. Call next_question with no id.`);
|
|
815
|
+
}
|
|
816
|
+
return questionResult(known, deps);
|
|
817
|
+
}
|
|
818
|
+
const wait = Math.min((args.maxWaitSec ?? 30) * 1000, NEXT_QUESTION_WAIT_MS);
|
|
819
|
+
const question = await session.nextQuestion(wait);
|
|
820
|
+
if (question != null)
|
|
821
|
+
return questionResult(question, deps);
|
|
822
|
+
const outstanding = session.outstandingQuestions();
|
|
823
|
+
if (outstanding.length > 0) {
|
|
824
|
+
return ok(`No new question. ${outstanding.length} already put to you and still unanswered:`
|
|
825
|
+
+ ` ${outstanding.map(one => one.id).join(', ')}. Send the person's answer with`
|
|
826
|
+
+ ' answer_question, or call next_question with that questionId to read it again.');
|
|
827
|
+
}
|
|
828
|
+
// Nothing queued here, which does not mean nothing is being asked: a run that parked while
|
|
829
|
+
// this connector was away is waiting on a question whose operation has long expired.
|
|
830
|
+
const parked = project != null ? await parkedQuestion(deps, project, jobId) : null;
|
|
831
|
+
if (parked != null) {
|
|
832
|
+
return questionResult(parked, deps, 'This run parked on a question before this session attached. It is still open:');
|
|
833
|
+
}
|
|
834
|
+
return ok('No question right now. If a job is still running, call wait_for; if it reported'
|
|
835
|
+
+ ' "blocked on: question", call next_question again.');
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: 'answer_question',
|
|
840
|
+
title: 'Send back what they decided',
|
|
841
|
+
description: 'Send the person\'s answer to a question the platform asked. If it is not one the question'
|
|
842
|
+
+ ' allows, this says so and the question stays open. Answer with declined: true when'
|
|
843
|
+
+ ' nobody is available to decide — the platform then assumes and records an assumption.',
|
|
844
|
+
input: {
|
|
845
|
+
questionId: z.string(),
|
|
846
|
+
answer: z.union([z.string(), z.array(z.string())]).optional(),
|
|
847
|
+
text: z.string().optional().describe('What they said, where the question takes words.'),
|
|
848
|
+
declined: z.boolean().optional().describe('Nobody could decide this.'),
|
|
849
|
+
// Taken here for the same reason `next_question` takes it, and it has to be the SAME reach:
|
|
850
|
+
// a question this tool cannot find is refused after a person has already answered it, and
|
|
851
|
+
// the parent is sent back to `next_question`, which would offer it again through the job.
|
|
852
|
+
jobId: z.string().optional()
|
|
853
|
+
.describe('The job that reported "blocked on: question", if you have its id.'),
|
|
854
|
+
projectId: z.string().optional(),
|
|
855
|
+
},
|
|
856
|
+
availability: sessionCapable,
|
|
857
|
+
run: async (args, deps) => {
|
|
858
|
+
const session = await deps.session();
|
|
859
|
+
const questionId = args.questionId;
|
|
860
|
+
const project = projectOrNull(args, deps);
|
|
861
|
+
const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
|
|
862
|
+
// The question this session is holding, if it still is. That is what decides HOW the answer
|
|
863
|
+
// travels: an operation the platform is waiting on, or the question's own id.
|
|
864
|
+
const held = session.questionById(questionId);
|
|
865
|
+
const inquiry = held ?? (project != null ? await parkedQuestion(deps, project, jobId) : null);
|
|
866
|
+
if (inquiry == null || inquiry.id !== questionId) {
|
|
867
|
+
return fail(`No question ${questionId} is waiting. Call next_question for the current one.`);
|
|
868
|
+
}
|
|
869
|
+
const { answer, problem } = parseAnswer(inquiry, args);
|
|
870
|
+
if (problem != null) {
|
|
871
|
+
// Refused HERE, with the person still in front of the parent — a mismatch the platform
|
|
872
|
+
// catches costs a round trip on a question a human has already answered.
|
|
873
|
+
return fail(problem);
|
|
874
|
+
}
|
|
875
|
+
if (held != null) {
|
|
876
|
+
await session.answerQuestion(answer);
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
if (project == null) {
|
|
880
|
+
return fail('No project. Call attach_project first, or name one with projectId.');
|
|
881
|
+
}
|
|
882
|
+
await deps.api.inquiry.answer(project, questionId, answer);
|
|
883
|
+
}
|
|
884
|
+
return ok('Recorded. Call wait_for on the job.');
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
name: 'check_convertible',
|
|
889
|
+
title: 'Can this application be converted',
|
|
890
|
+
description: 'Say whether the platform can convert an existing codebase, what it is built with, what will'
|
|
891
|
+
+ ' be limited, and what the next stage will cost. Nothing is provisioned and nothing is'
|
|
892
|
+
+ ' charged. It reads what the intake found, so convert_project starts one first; on a'
|
|
893
|
+
+ ' conversion already under way it also says where that one stands.',
|
|
894
|
+
input: { projectId: z.string().optional() },
|
|
895
|
+
availability: anyHost,
|
|
896
|
+
run: async (args, deps) => await answering(deps, 'check_convertible', async () => {
|
|
897
|
+
const project = projectOf(args, deps);
|
|
898
|
+
// The platform reads the tree through THIS connector when it lives on this machine, so the
|
|
899
|
+
// session has to exist before the check runs rather than after it.
|
|
900
|
+
await ensureSession(deps, project);
|
|
901
|
+
const check = await deps.api.convert.check(project);
|
|
902
|
+
return ok(
|
|
903
|
+
// Read after the check rather than before it: the check is what this tool is for, and the
|
|
904
|
+
// conversion only decides what to do NEXT with what it said.
|
|
905
|
+
renderCheck(check, await conversionUnderWay(deps, project)), check);
|
|
906
|
+
}),
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
name: 'convert_project',
|
|
910
|
+
title: 'Convert an application you already have',
|
|
911
|
+
description: 'Bring an existing application onto the platform: it reads the code, restores the'
|
|
912
|
+
+ ' specification and the user stories nobody wrote down, and rebuilds it on the platform\'s'
|
|
913
|
+
+ ' stack. The original is kept beside it. Returns a job, and stops at your decision after'
|
|
914
|
+
+ ' each stage.',
|
|
915
|
+
input: {
|
|
916
|
+
projectId: z.string().optional().describe('Convert into a project that already exists.'),
|
|
917
|
+
name: z.string().optional(),
|
|
918
|
+
about: z.string().optional().describe('What the application is for, in your own words.'),
|
|
919
|
+
repoUrl: z.string().optional()
|
|
920
|
+
.describe('A GitHub repository to convert. Named, it is converted rather than this directory.'),
|
|
921
|
+
branch: z.string().optional(),
|
|
922
|
+
},
|
|
923
|
+
availability: anyHost,
|
|
924
|
+
run: async (args, deps) => await answering(deps, 'convert_project', async () => {
|
|
925
|
+
const named = typeof args.projectId === 'string' ? args.projectId : deps.attached();
|
|
926
|
+
if (named != null) {
|
|
927
|
+
await ensureSession(deps, named);
|
|
928
|
+
return jobResult(await deps.api.convert.start(named));
|
|
929
|
+
}
|
|
930
|
+
const repoUrl = typeof args.repoUrl === 'string' && args.repoUrl.trim() !== ''
|
|
931
|
+
? args.repoUrl.trim()
|
|
932
|
+
: null;
|
|
933
|
+
if (deps.dir == null && repoUrl == null) {
|
|
934
|
+
return fail('Nothing to convert. Name a project, attach one, run the connector in the directory you'
|
|
935
|
+
+ ' want converted, or give a repoUrl.');
|
|
936
|
+
}
|
|
937
|
+
const job = await deps.api.convert.create({
|
|
938
|
+
...(typeof args.name === 'string' ? { name: args.name } : {}),
|
|
939
|
+
...(typeof args.about === 'string' ? { about: args.about } : {}),
|
|
940
|
+
// A NAMED repository outranks the directory this connector runs in. A stdio connector
|
|
941
|
+
// always has a directory, so reading that first would convert the caller's own working
|
|
942
|
+
// copy instead of the repository it asked for — with `repoUrl` and `branch` dropped in
|
|
943
|
+
// silence, which is the one outcome nobody could diagnose from the answer.
|
|
944
|
+
...(repoUrl == null
|
|
945
|
+
// The directory IS the origin: nothing is cloned, and the relocation and the template
|
|
946
|
+
// install run as ordinary operations through this connector.
|
|
947
|
+
? { target: ConnectTarget.Local, origin: { kind: OriginKind.Local } }
|
|
948
|
+
: {
|
|
949
|
+
target: ConnectTarget.Cloud,
|
|
950
|
+
origin: {
|
|
951
|
+
kind: OriginKind.Github,
|
|
952
|
+
repoUrl,
|
|
953
|
+
...(typeof args.branch === 'string' ? { branch: args.branch } : {}),
|
|
954
|
+
},
|
|
955
|
+
}),
|
|
956
|
+
});
|
|
957
|
+
deps.attach(job.projectId);
|
|
958
|
+
await ensureSession(deps, job.projectId);
|
|
959
|
+
return jobResult(await deps.api.convert.start(job.projectId));
|
|
960
|
+
}),
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
name: 'proceed_conversion',
|
|
964
|
+
title: 'Decide what a conversion does next',
|
|
965
|
+
description: 'A conversion stops after each stage and waits for a decision: analyze what was read,'
|
|
966
|
+
+ ' extract the user stories, implement them, leave it as it stands, retry a stage that'
|
|
967
|
+
+ ' failed, or cancel. Returns a job for anything that runs.',
|
|
968
|
+
input: {
|
|
969
|
+
decision: z.enum(Object.values(ConversionDecision)),
|
|
970
|
+
projectId: z.string().optional(),
|
|
971
|
+
note: z.string().optional().describe('What the user said about the decision. Recorded.'),
|
|
972
|
+
},
|
|
973
|
+
availability: anyHost,
|
|
974
|
+
run: async (args, deps) => await answering(deps, 'proceed_conversion', async () => {
|
|
975
|
+
const project = projectOf(args, deps);
|
|
976
|
+
await ensureSession(deps, project);
|
|
977
|
+
const job = await deps.api.convert.proceed(project, args.decision, typeof args.note === 'string' ? args.note : undefined);
|
|
978
|
+
return jobResult(job);
|
|
979
|
+
}),
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: 'conversion_status',
|
|
983
|
+
title: 'Where a conversion stands',
|
|
984
|
+
description: 'The stage a conversion has reached, what it found, what it has cost so far, whether the'
|
|
985
|
+
+ ' original sources are still there, and what to call next.',
|
|
986
|
+
input: { projectId: z.string().optional() },
|
|
987
|
+
availability: anyHost,
|
|
988
|
+
run: async (args, deps) => await answering(deps, 'conversion_status', async () => {
|
|
989
|
+
const view = await deps.api.convert.status(projectOf(args, deps));
|
|
990
|
+
return ok(renderConversion(view), view);
|
|
991
|
+
}),
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: 'purge_origin',
|
|
995
|
+
title: 'Delete the converted original',
|
|
996
|
+
description: 'Delete the original sources a conversion kept beside the converted project, and rewrite'
|
|
997
|
+
+ ' the conversion documents so they stop quoting them. This CANNOT be undone — ask the'
|
|
998
|
+
+ ' user before calling it.',
|
|
999
|
+
input: {
|
|
1000
|
+
projectId: z.string().optional(),
|
|
1001
|
+
confirm: z.boolean().describe('Must be true. The user has to have agreed to this.'),
|
|
1002
|
+
},
|
|
1003
|
+
availability: anyHost,
|
|
1004
|
+
run: async (args, deps) => await answering(deps, 'purge_origin', async () => {
|
|
1005
|
+
if (args.confirm !== true) {
|
|
1006
|
+
return fail('Not done. This deletes the original sources under __viable_converted/ and every'
|
|
1007
|
+
+ ' reference to them, and cannot be undone. Ask the user, then call it again with'
|
|
1008
|
+
+ ' confirm: true.');
|
|
1009
|
+
}
|
|
1010
|
+
const project = projectOf(args, deps);
|
|
1011
|
+
await ensureSession(deps, project);
|
|
1012
|
+
return jobResult(await deps.api.convert.purge(project));
|
|
1013
|
+
}),
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
name: 'list_files',
|
|
1017
|
+
title: 'The generated project\'s files',
|
|
1018
|
+
description: 'What the platform generated, for a project whose sources live in its own slot.',
|
|
1019
|
+
input: { projectId: z.string().optional() },
|
|
1020
|
+
availability: cloudTarget,
|
|
1021
|
+
run: async (args, deps) => {
|
|
1022
|
+
const project = projectOf(args, deps);
|
|
1023
|
+
const files = await deps.api.files.list(project);
|
|
1024
|
+
return ok(files.length > 0
|
|
1025
|
+
? `${files.length} generated file(s):\n${files.join('\n')}`
|
|
1026
|
+
: 'No generated files were found.', { projectId: project, total: files.length, files });
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
name: 'local_status',
|
|
1031
|
+
title: 'Is this machine ready to run the app',
|
|
1032
|
+
description: 'What the generated project still needs before it can start here, and whether it is running.',
|
|
1033
|
+
input: {},
|
|
1034
|
+
availability: localTarget,
|
|
1035
|
+
run: async (_args, deps) => {
|
|
1036
|
+
if (deps.dir == null)
|
|
1037
|
+
return fail('No project directory.');
|
|
1038
|
+
const [env, running] = await Promise.all([envStatus(deps.dir), localStatus(deps.dir)]);
|
|
1039
|
+
const lines = [
|
|
1040
|
+
`directory: ${deps.dir}`,
|
|
1041
|
+
env.missing.length < 1
|
|
1042
|
+
? 'configuration: complete'
|
|
1043
|
+
: `configuration: missing ${env.missing.join(', ')} — see local_setup_guide`,
|
|
1044
|
+
running.running
|
|
1045
|
+
? `running: api ${running.api ?? '?'} · web ${running.web ?? '?'}`
|
|
1046
|
+
+ (running.phase != null ? ` · ${running.phase}` : '')
|
|
1047
|
+
: 'running: no — run_local starts it',
|
|
1048
|
+
];
|
|
1049
|
+
return ok(lines.join('\n'), { env, running });
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
name: 'run_local',
|
|
1054
|
+
title: 'Build and start the app on this machine',
|
|
1055
|
+
description: 'Build the generated project and start it: the API on 3000, the app on 5173. The build takes'
|
|
1056
|
+
+ ' a while — this returns once it has started, and local_status says when it is up.',
|
|
1057
|
+
input: { build: z.boolean().optional().describe('Build first. Defaults to true.') },
|
|
1058
|
+
availability: localTarget,
|
|
1059
|
+
run: async (args, deps) => {
|
|
1060
|
+
if (deps.dir == null)
|
|
1061
|
+
return fail('No project directory.');
|
|
1062
|
+
const env = await envStatus(deps.dir);
|
|
1063
|
+
if (env.missing.length > 0) {
|
|
1064
|
+
// Refused rather than started: an app launched without a database URL fails at boot with a
|
|
1065
|
+
// message about a connection, which sends the reader looking for a database that was never
|
|
1066
|
+
// configured in the first place.
|
|
1067
|
+
return fail(`The project still needs ${env.missing.join(', ')} in its .env. Call local_setup_guide,`
|
|
1068
|
+
+ ' which reports what is missing and what to ask the user for.');
|
|
1069
|
+
}
|
|
1070
|
+
const started = await runLocal(deps.dir, { build: args.build !== false });
|
|
1071
|
+
return ok(`Started. The app is at http://localhost:${started.web}, its API at`
|
|
1072
|
+
+ ` http://localhost:${started.api}. Call local_status if it does not answer yet.`, started);
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
name: 'stop_local',
|
|
1077
|
+
title: 'Stop the app on this machine',
|
|
1078
|
+
description: 'Stop the application run_local started here: its API, its worker and the server holding'
|
|
1079
|
+
+ ' port 5173. Leaves the project files alone.',
|
|
1080
|
+
input: {},
|
|
1081
|
+
availability: localTarget,
|
|
1082
|
+
run: async (_args, deps) => {
|
|
1083
|
+
if (deps.dir == null)
|
|
1084
|
+
return fail('No project directory.');
|
|
1085
|
+
await stopLocal(deps.dir);
|
|
1086
|
+
return ok('Stopped.');
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: 'local_setup_guide',
|
|
1091
|
+
title: 'How to run the generated app on this machine',
|
|
1092
|
+
description: 'What to install and what to put in the project\'s .env so the generated application can'
|
|
1093
|
+
+ ' start here — a database, optionally a queue store, and the ports it will use.',
|
|
1094
|
+
input: {},
|
|
1095
|
+
availability: localTarget,
|
|
1096
|
+
run: async (_args, deps) => {
|
|
1097
|
+
if (deps.dir == null)
|
|
1098
|
+
return fail('No project directory.');
|
|
1099
|
+
const report = await readSetupReport(deps.dir);
|
|
1100
|
+
return ok(renderSetupGuide(report), report);
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
name: 'set_local_service',
|
|
1105
|
+
title: 'Record the database or queue the user chose',
|
|
1106
|
+
description: 'Write a connection string the USER supplied into the project\'s own half of its .env, then'
|
|
1107
|
+
+ ' check that something answers there. Call it with what they gave you after asking —'
|
|
1108
|
+
+ ' never with a value you composed yourself.',
|
|
1109
|
+
input: {
|
|
1110
|
+
databaseUrl: z.string().optional()
|
|
1111
|
+
.describe('postgres://user:password@host:port/database'),
|
|
1112
|
+
valkeyUrl: z.string().optional()
|
|
1113
|
+
.describe('redis://host:port — only for a project with a background worker'),
|
|
1114
|
+
schema: z.string().optional().describe('Postgres schema. Defaults to "app".'),
|
|
1115
|
+
},
|
|
1116
|
+
availability: localTarget,
|
|
1117
|
+
run: async (args, deps) => {
|
|
1118
|
+
if (deps.dir == null)
|
|
1119
|
+
return fail('No project directory.');
|
|
1120
|
+
const databaseUrl = typeof args.databaseUrl === 'string' ? args.databaseUrl.trim() : '';
|
|
1121
|
+
const valkeyUrl = typeof args.valkeyUrl === 'string' ? args.valkeyUrl.trim() : '';
|
|
1122
|
+
if (databaseUrl === '' && valkeyUrl === '') {
|
|
1123
|
+
return fail('Give at least one of databaseUrl or valkeyUrl — the value the user supplied.');
|
|
1124
|
+
}
|
|
1125
|
+
const { written, file } = await setUserEnv(deps.dir, {
|
|
1126
|
+
...(databaseUrl !== '' ? { DATABASE_URL: databaseUrl } : {}),
|
|
1127
|
+
...(databaseUrl !== ''
|
|
1128
|
+
? { DATABASE_SCHEMA: typeof args.schema === 'string' && args.schema.trim() !== ''
|
|
1129
|
+
? args.schema.trim()
|
|
1130
|
+
: 'app' }
|
|
1131
|
+
: {}),
|
|
1132
|
+
...(valkeyUrl !== '' ? { VALKEY_URL: valkeyUrl } : {}),
|
|
1133
|
+
});
|
|
1134
|
+
// Probed straight away, because a value that does not answer is the whole failure this tool
|
|
1135
|
+
// exists to prevent: accepted silently, it surfaces minutes later as a boot error about a
|
|
1136
|
+
// connection, and the reader goes looking for a database rather than for a typo.
|
|
1137
|
+
const report = await readSetupReport(deps.dir);
|
|
1138
|
+
const lines = [`Wrote ${written.join(', ')} into ${file}, outside the managed block.`];
|
|
1139
|
+
if (databaseUrl !== '') {
|
|
1140
|
+
lines.push(report.database.reachable
|
|
1141
|
+
? 'The database answers.'
|
|
1142
|
+
: 'The database does NOT answer yet. Check the host, the port and whether it is running.');
|
|
1143
|
+
}
|
|
1144
|
+
if (valkeyUrl !== '') {
|
|
1145
|
+
lines.push(report.queue.reachable
|
|
1146
|
+
? 'The queue store answers.'
|
|
1147
|
+
: 'The queue store does NOT answer yet.');
|
|
1148
|
+
}
|
|
1149
|
+
if (!report.envIgnored) {
|
|
1150
|
+
lines.push('That file is not git-ignored. Tell the user, so a credential does not reach a commit.');
|
|
1151
|
+
}
|
|
1152
|
+
lines.push(missingServices(report).length < 1
|
|
1153
|
+
? 'Nothing else is missing — run_local can build and start the application.'
|
|
1154
|
+
: 'Still missing: ' + missingServices(report).join(', ') + '. Call local_setup_guide again.');
|
|
1155
|
+
return ok(lines.join('\n'), report);
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
];
|
|
1159
|
+
const renderStories = (items, page, total) => items.length < 1
|
|
1160
|
+
? 'No stories.'
|
|
1161
|
+
: `${items.length} of ${total} (page ${page}):\n`
|
|
1162
|
+
+ items.map(item => ` ${String(item.code ?? item.id)} · ${String(item.status)}`
|
|
1163
|
+
+ `${item.primary === true ? ' · primary' : ''}`
|
|
1164
|
+
+ `${item.area != null ? ` · ${String(item.area)}` : ''}\n ${String(item.story).slice(0, 160)}`).join('\n');
|
|
1165
|
+
/** The tools a given host actually offers. */
|
|
1166
|
+
export const visibleTools = (host) => catalogue.filter(tool => tool.availability(host));
|
|
1167
|
+
export const toolByName = (name) => catalogue.find(tool => tool.name === name);
|
|
1168
|
+
export { ToolHostKind };
|
|
1169
|
+
//# sourceMappingURL=catalogue.js.map
|