@marifold/service 0.60.0
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/LICENSE +674 -0
- package/dist/MarifoldService.d.ts +43 -0
- package/dist/MarifoldService.d.ts.map +1 -0
- package/dist/MarifoldService.js +851 -0
- package/dist/MarifoldService.js.map +1 -0
- package/dist/ProfileRoutes.d.ts +12 -0
- package/dist/ProfileRoutes.d.ts.map +1 -0
- package/dist/ProfileRoutes.js +288 -0
- package/dist/ProfileRoutes.js.map +1 -0
- package/dist/RunRoutes.d.ts +10 -0
- package/dist/RunRoutes.d.ts.map +1 -0
- package/dist/RunRoutes.js +123 -0
- package/dist/RunRoutes.js.map +1 -0
- package/dist/Security.d.ts +27 -0
- package/dist/Security.d.ts.map +1 -0
- package/dist/Security.js +231 -0
- package/dist/Security.js.map +1 -0
- package/dist/Sse.d.ts +17 -0
- package/dist/Sse.d.ts.map +1 -0
- package/dist/Sse.js +37 -0
- package/dist/Sse.js.map +1 -0
- package/dist/StaticRoutes.d.ts +16 -0
- package/dist/StaticRoutes.d.ts.map +1 -0
- package/dist/StaticRoutes.js +145 -0
- package/dist/StaticRoutes.js.map +1 -0
- package/dist/Validation.d.ts +19 -0
- package/dist/Validation.d.ts.map +1 -0
- package/dist/Validation.js +125 -0
- package/dist/Validation.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/web/assets/index-Cwd6L0Gh.js +31 -0
- package/dist/web/assets/index-FYW6vjJ9.css +1 -0
- package/dist/web/assets/officeAttachments-DGKi_KQx.js +17 -0
- package/dist/web/index.html +18 -0
- package/package.json +28 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createMarifoldService = createMarifoldService;
|
|
7
|
+
exports.startMarifoldService = startMarifoldService;
|
|
8
|
+
const fastify_1 = __importDefault(require("fastify"));
|
|
9
|
+
const core_1 = require("@marifold/core");
|
|
10
|
+
const ProfileRoutes_1 = require("./ProfileRoutes");
|
|
11
|
+
const RunRoutes_1 = require("./RunRoutes");
|
|
12
|
+
const Security_1 = require("./Security");
|
|
13
|
+
const StaticRoutes_1 = require("./StaticRoutes");
|
|
14
|
+
const Sse_1 = require("./Sse");
|
|
15
|
+
const Validation_1 = require("./Validation");
|
|
16
|
+
const API_VERSION = 'v1';
|
|
17
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
18
|
+
const DEFAULT_PORT = 32140;
|
|
19
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
20
|
+
/** Base64 image attachments ride the JSON body; fastify's 1 MiB default
|
|
21
|
+
* would reject them. Still gated by bind scope, source network, CORS, and any
|
|
22
|
+
* configured bearer auth whenever the service leaves loopback. */
|
|
23
|
+
const BODY_LIMIT_BYTES = 25 * 1024 * 1024;
|
|
24
|
+
function createMarifoldService(options) {
|
|
25
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
26
|
+
const security = (0, Security_1.resolveSecurityOptions)(options.loadedConfig.config.service, {
|
|
27
|
+
token: options.auth?.token,
|
|
28
|
+
corsOrigins: options.cors?.origins,
|
|
29
|
+
});
|
|
30
|
+
const runtime = new core_1.MarifoldRuntime({ loadedConfig: options.loadedConfig });
|
|
31
|
+
const server = (0, fastify_1.default)({ logger: options.logger ?? false, bodyLimit: BODY_LIMIT_BYTES });
|
|
32
|
+
(0, Security_1.registerSecurity)(server, {
|
|
33
|
+
...security,
|
|
34
|
+
access: LOOPBACK_HOSTS.has(host) ? 'loopback' : 'private',
|
|
35
|
+
boundHost: host,
|
|
36
|
+
});
|
|
37
|
+
const scheduler = (options.scheduler ?? true)
|
|
38
|
+
? runtime.createScheduler(message => server.log.info(message))
|
|
39
|
+
: undefined;
|
|
40
|
+
scheduler?.start();
|
|
41
|
+
// Messaging bridge(s) run inside the same long-lived process as the HTTP API
|
|
42
|
+
// and scheduler, so one `marifold service` powers everything (TUI, future
|
|
43
|
+
// Web/desktop/mobile, Telegram).
|
|
44
|
+
const telegramBridge = runtime.createTelegramBridge(message => server.log.info(message));
|
|
45
|
+
telegramBridge?.start();
|
|
46
|
+
server.marifoldTelegram = telegramBridge ? { profile: telegramBridge.profile } : undefined;
|
|
47
|
+
const runRegistry = runtime.createRunRegistry(message => server.log.info(message));
|
|
48
|
+
const skillAppInstances = runtime.createSkillAppInstanceRegistry();
|
|
49
|
+
// Plain /ask and /chat/stream requests are not RunRegistry entries, but they
|
|
50
|
+
// can still persist a final exchange. Keep session-scoped requests visible
|
|
51
|
+
// to destructive history routes so a late completion cannot recreate a
|
|
52
|
+
// session that was just deleted or truncated.
|
|
53
|
+
const activeSessionRequests = new Map();
|
|
54
|
+
const activeProfileRequests = new Map();
|
|
55
|
+
const beginSessionRequest = (sessionId, profile) => {
|
|
56
|
+
if (sessionId)
|
|
57
|
+
activeSessionRequests.set(sessionId, (activeSessionRequests.get(sessionId) ?? 0) + 1);
|
|
58
|
+
if (profile)
|
|
59
|
+
activeProfileRequests.set(profile, (activeProfileRequests.get(profile) ?? 0) + 1);
|
|
60
|
+
return () => {
|
|
61
|
+
if (sessionId) {
|
|
62
|
+
const remaining = (activeSessionRequests.get(sessionId) ?? 1) - 1;
|
|
63
|
+
if (remaining > 0)
|
|
64
|
+
activeSessionRequests.set(sessionId, remaining);
|
|
65
|
+
else
|
|
66
|
+
activeSessionRequests.delete(sessionId);
|
|
67
|
+
}
|
|
68
|
+
if (profile) {
|
|
69
|
+
const remaining = (activeProfileRequests.get(profile) ?? 1) - 1;
|
|
70
|
+
if (remaining > 0)
|
|
71
|
+
activeProfileRequests.set(profile, remaining);
|
|
72
|
+
else
|
|
73
|
+
activeProfileRequests.delete(profile);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const hasActiveSessionRequest = (sessionId) => (activeSessionRequests.get(sessionId) ?? 0) > 0;
|
|
78
|
+
(0, RunRoutes_1.registerRunRoutes)(server, runRegistry);
|
|
79
|
+
(0, ProfileRoutes_1.registerProfileRoutes)(server, runtime, {
|
|
80
|
+
isProfileActive: profile => (activeProfileRequests.get(profile) ?? 0) > 0
|
|
81
|
+
|| runRegistry.list().some(run => run.profile === profile && run.finishedAt === undefined),
|
|
82
|
+
});
|
|
83
|
+
const webDir = resolveServiceWebDir(options);
|
|
84
|
+
if (webDir)
|
|
85
|
+
(0, StaticRoutes_1.registerStaticRoutes)(server, webDir);
|
|
86
|
+
server.addHook('onClose', async () => {
|
|
87
|
+
telegramBridge?.stop();
|
|
88
|
+
scheduler?.stop();
|
|
89
|
+
runRegistry.close();
|
|
90
|
+
skillAppInstances.close();
|
|
91
|
+
runtime.close();
|
|
92
|
+
});
|
|
93
|
+
server.setErrorHandler((error, _request, reply) => {
|
|
94
|
+
const normalized = normalizeError(error);
|
|
95
|
+
reply.status(normalized.statusCode).send({
|
|
96
|
+
ok: false,
|
|
97
|
+
error: normalized.error,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
server.setNotFoundHandler((request, reply) => {
|
|
101
|
+
reply.status(404).send({
|
|
102
|
+
ok: false,
|
|
103
|
+
error: {
|
|
104
|
+
code: 'NOT_FOUND',
|
|
105
|
+
message: `Route not found: ${request.method} ${request.url}`,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
server.get('/health', async () => ({
|
|
110
|
+
ok: true,
|
|
111
|
+
service: 'marifold',
|
|
112
|
+
apiVersion: API_VERSION,
|
|
113
|
+
}));
|
|
114
|
+
server.get('/v1/status', async () => ({
|
|
115
|
+
ok: true,
|
|
116
|
+
service: 'marifold',
|
|
117
|
+
apiVersion: API_VERSION,
|
|
118
|
+
localOnly: true,
|
|
119
|
+
configPath: options.loadedConfig.configPath,
|
|
120
|
+
foundConfig: options.loadedConfig.foundConfig,
|
|
121
|
+
default: options.loadedConfig.config.default,
|
|
122
|
+
paths: options.loadedConfig.config.paths,
|
|
123
|
+
}));
|
|
124
|
+
server.get('/v1/config', async () => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
config: publicConfig(options.loadedConfig, Boolean(security.token)),
|
|
127
|
+
}));
|
|
128
|
+
// Mirrors the CLI's `config set <key> <value>` exactly (same dotted-key
|
|
129
|
+
// routing and validation); returns the sanitized view, never raw secrets.
|
|
130
|
+
server.patch('/v1/config', async (request) => {
|
|
131
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
132
|
+
runtime.setConfigValue((0, Validation_1.requiredString)(body.key, 'key'), (0, Validation_1.stringValue)(body.value, 'value'));
|
|
133
|
+
return { ok: true, config: publicConfig(options.loadedConfig, Boolean(security.token)) };
|
|
134
|
+
});
|
|
135
|
+
server.get('/v1/providers', async () => ({
|
|
136
|
+
ok: true,
|
|
137
|
+
providers: Object.entries(options.loadedConfig.config.providers)
|
|
138
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
139
|
+
.map(([name, provider]) => ({
|
|
140
|
+
name,
|
|
141
|
+
...publicProvider(provider),
|
|
142
|
+
})),
|
|
143
|
+
}));
|
|
144
|
+
// Live reachability probe for every provider (CLI `provider status`).
|
|
145
|
+
// Sanitized: key/token presence booleans and env-var *names* only.
|
|
146
|
+
server.get('/v1/providers/status', async () => ({
|
|
147
|
+
ok: true,
|
|
148
|
+
providers: await runtime.providerStatus(),
|
|
149
|
+
}));
|
|
150
|
+
// Models the provider actually serves right now (feeds the model picker).
|
|
151
|
+
server.get('/v1/providers/:name/models', async (request) => ({
|
|
152
|
+
ok: true,
|
|
153
|
+
...(await runtime.listProviderModels(request.params.name)),
|
|
154
|
+
}));
|
|
155
|
+
server.delete('/v1/providers/:name', async (request) => {
|
|
156
|
+
const result = runtime.removeProvider(request.params.name);
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
...result,
|
|
160
|
+
config: publicConfig(options.loadedConfig, Boolean(security.token)),
|
|
161
|
+
models: modelsView(options.loadedConfig),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
// Available skills (name + usage) for the composer's $-autocomplete,
|
|
165
|
+
// profile-scoped so profile skills shadow global ones.
|
|
166
|
+
server.get('/v1/skills', async (request) => ({
|
|
167
|
+
ok: true,
|
|
168
|
+
skills: runtime.listSkills(request.query.profile).map(skillHint),
|
|
169
|
+
}));
|
|
170
|
+
// Resolve a `$skill [args]` invocation in code so Web/service clients do not
|
|
171
|
+
// spend an agent loop searching the filesystem for a skill already indexed
|
|
172
|
+
// by Marifold.
|
|
173
|
+
server.post('/v1/skills/resolve', async (request) => {
|
|
174
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
175
|
+
const profile = (0, Validation_1.optionalStringField)('profile', body.profile).profile;
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
invocation: runtime.resolveSkillInvocation((0, Validation_1.requiredString)(body.invocation, 'invocation'), profile),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
// SkillApp source stays server-owned. Every renderer receives the same
|
|
182
|
+
// statically compiled JSON contract and can only submit typed state.
|
|
183
|
+
server.get('/v1/apps', async () => ({
|
|
184
|
+
ok: true,
|
|
185
|
+
apps: runtime.listApps(),
|
|
186
|
+
}));
|
|
187
|
+
server.get('/v1/apps/:name', async (request, reply) => {
|
|
188
|
+
const app = runtime.getApp(request.params.name);
|
|
189
|
+
if (!app) {
|
|
190
|
+
reply.status(404);
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: {
|
|
194
|
+
code: 'APP_NOT_FOUND',
|
|
195
|
+
message: `App not found: ${request.params.name}`,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, app };
|
|
200
|
+
});
|
|
201
|
+
// SkillApps are statically compiled templates with ephemeral service-owned
|
|
202
|
+
// state. Both buttons and on-change triggers execute the same declared
|
|
203
|
+
// model + app-local Skill operation and return a normalized result.
|
|
204
|
+
server.post('/v1/apps/:name/instances', async (request, reply) => {
|
|
205
|
+
const instance = skillAppInstances.create(request.params.name);
|
|
206
|
+
reply.status(201);
|
|
207
|
+
return { ok: true, instance };
|
|
208
|
+
});
|
|
209
|
+
server.patch('/v1/app-instances/:id/state', async (request) => {
|
|
210
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
...(await skillAppInstances.update(request.params.id, (0, Validation_1.objectBody)(body.values))),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
server.post('/v1/app-instances/:id/operations/:operation', async (request) => ({
|
|
217
|
+
ok: true,
|
|
218
|
+
...(await skillAppInstances.run(request.params.id, request.params.operation)),
|
|
219
|
+
}));
|
|
220
|
+
server.delete('/v1/app-instances/:id', async (request) => ({
|
|
221
|
+
ok: true,
|
|
222
|
+
deleted: skillAppInstances.delete(request.params.id),
|
|
223
|
+
}));
|
|
224
|
+
server.get('/v1/models', async () => ({
|
|
225
|
+
ok: true,
|
|
226
|
+
default: {
|
|
227
|
+
provider: options.loadedConfig.config.default.provider,
|
|
228
|
+
model: options.loadedConfig.config.default.model,
|
|
229
|
+
},
|
|
230
|
+
options: [...options.loadedConfig.config.models.options],
|
|
231
|
+
}));
|
|
232
|
+
// Model management (CLI `model add`/`rm`/`default`). Provider entries may be
|
|
233
|
+
// created/updated here, but never with secrets — raw api_key values stay
|
|
234
|
+
// CLI/file-only by design; the wire accepts the env-var *name* at most.
|
|
235
|
+
server.post('/v1/models', async (request, reply) => {
|
|
236
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
237
|
+
runtime.addModelOption((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'), {
|
|
238
|
+
...(body.type !== undefined ? { type: parseProviderTypeField(body.type) } : {}),
|
|
239
|
+
...(0, Validation_1.optionalStringField)('baseUrl', body.baseUrl),
|
|
240
|
+
...(0, Validation_1.optionalStringField)('apiKeyEnv', body.apiKeyEnv),
|
|
241
|
+
});
|
|
242
|
+
reply.status(201);
|
|
243
|
+
return modelsView(options.loadedConfig);
|
|
244
|
+
});
|
|
245
|
+
server.delete('/v1/models', async (request) => {
|
|
246
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
247
|
+
const result = runtime.removeModelOption((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'));
|
|
248
|
+
return { ...modelsView(options.loadedConfig), ...result };
|
|
249
|
+
});
|
|
250
|
+
server.put('/v1/models/default', async (request) => {
|
|
251
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
252
|
+
runtime.setDefaultModel((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'));
|
|
253
|
+
return modelsView(options.loadedConfig);
|
|
254
|
+
});
|
|
255
|
+
server.get('/v1/profiles', async () => ({
|
|
256
|
+
ok: true,
|
|
257
|
+
profiles: runtime.listProfiles(),
|
|
258
|
+
}));
|
|
259
|
+
server.get('/v1/profiles/:name', async (request) => ({
|
|
260
|
+
ok: true,
|
|
261
|
+
profile: runtime.getProfile(request.params.name),
|
|
262
|
+
}));
|
|
263
|
+
server.get('/v1/profiles/:name/memories', async (request) => {
|
|
264
|
+
const includeSuperseded = parseBooleanQuery(request.query.all);
|
|
265
|
+
const limit = parseLimitQuery(request.query.limit);
|
|
266
|
+
const entries = runtime.listMemories(request.params.name, includeSuperseded);
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
profile: request.params.name,
|
|
270
|
+
memories: limit === undefined ? entries : entries.slice(0, limit),
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
server.get('/v1/sessions', async (request) => ({
|
|
274
|
+
ok: true,
|
|
275
|
+
sessions: runtime.listSessions(parseLimitQuery(request.query.limit) ?? 50, request.query.profile, {
|
|
276
|
+
archived: parseBooleanQuery(request.query.archived),
|
|
277
|
+
...(request.query.q?.trim() ? { search: request.query.q } : {}),
|
|
278
|
+
}),
|
|
279
|
+
}));
|
|
280
|
+
server.get('/v1/sessions/:id', async (request, reply) => {
|
|
281
|
+
const session = runtime.getSession(request.params.id);
|
|
282
|
+
if (!session) {
|
|
283
|
+
reply.status(404);
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: {
|
|
287
|
+
code: 'SESSION_NOT_FOUND',
|
|
288
|
+
message: `Session not found: ${request.params.id}`,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, session };
|
|
293
|
+
});
|
|
294
|
+
server.get('/v1/sessions/:id/attachments/:userTurnIndex/:attachmentIndex', async (request, reply) => {
|
|
295
|
+
const userTurnIndex = nonNegativeIntegerPath(request.params.userTurnIndex, 'userTurnIndex');
|
|
296
|
+
const attachmentIndex = nonNegativeIntegerPath(request.params.attachmentIndex, 'attachmentIndex');
|
|
297
|
+
const attachment = runtime.getSessionAttachment(request.params.id, userTurnIndex, attachmentIndex);
|
|
298
|
+
if (!attachment?.data) {
|
|
299
|
+
reply.status(404);
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: {
|
|
303
|
+
code: 'SESSION_ATTACHMENT_NOT_FOUND',
|
|
304
|
+
message: 'Session attachment not found.',
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
reply
|
|
309
|
+
.type(attachment.mediaType)
|
|
310
|
+
.header('cache-control', 'private, max-age=60')
|
|
311
|
+
.header('x-content-type-options', 'nosniff');
|
|
312
|
+
return reply.send(Buffer.from(attachment.data, 'base64'));
|
|
313
|
+
});
|
|
314
|
+
server.patch('/v1/sessions/:id', async (request, reply) => {
|
|
315
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
316
|
+
const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
|
|
317
|
+
const hasPinned = Object.prototype.hasOwnProperty.call(body, 'pinned');
|
|
318
|
+
const hasArchived = Object.prototype.hasOwnProperty.call(body, 'archived');
|
|
319
|
+
if (!hasTitle && !hasPinned && !hasArchived) {
|
|
320
|
+
throw core_1.MarifoldError.configInvalid('At least one of title, pinned, or archived is required.');
|
|
321
|
+
}
|
|
322
|
+
if (hasTitle && body.title !== null && typeof body.title !== 'string') {
|
|
323
|
+
throw core_1.MarifoldError.configInvalid('title must be a string or null.');
|
|
324
|
+
}
|
|
325
|
+
if (hasPinned && typeof body.pinned !== 'boolean') {
|
|
326
|
+
throw core_1.MarifoldError.configInvalid('pinned must be a boolean.');
|
|
327
|
+
}
|
|
328
|
+
if (hasArchived && typeof body.archived !== 'boolean') {
|
|
329
|
+
throw core_1.MarifoldError.configInvalid('archived must be a boolean.');
|
|
330
|
+
}
|
|
331
|
+
const updated = runtime.updateSessionDisplay(request.params.id, {
|
|
332
|
+
...(hasTitle ? { title: body.title } : {}),
|
|
333
|
+
...(hasPinned ? { pinned: body.pinned } : {}),
|
|
334
|
+
...(hasArchived ? { archived: body.archived } : {}),
|
|
335
|
+
});
|
|
336
|
+
if (!updated) {
|
|
337
|
+
reply.status(404);
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
error: {
|
|
341
|
+
code: 'SESSION_NOT_FOUND',
|
|
342
|
+
message: `Session not found: ${request.params.id}`,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return { ok: true, session: runtime.getSession(request.params.id) };
|
|
347
|
+
});
|
|
348
|
+
server.delete('/v1/sessions/:id', async (request) => {
|
|
349
|
+
if (hasActiveSessionRequest(request.params.id)
|
|
350
|
+
|| runRegistry.list().some(run => run.sessionId === request.params.id && run.status === 'running')) {
|
|
351
|
+
throw core_1.MarifoldError.agentRunInvalid('Cancel the active request and wait for it to finish before deleting this session.');
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
deleted: runtime.deleteSession(request.params.id),
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
server.post('/v1/sessions/:id/truncate', async (request, reply) => {
|
|
359
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
360
|
+
const userTurnIndex = body.fromUserTurnIndex;
|
|
361
|
+
if (typeof userTurnIndex !== 'number' || !Number.isInteger(userTurnIndex) || userTurnIndex < 0) {
|
|
362
|
+
throw core_1.MarifoldError.configInvalid('fromUserTurnIndex must be a non-negative integer.');
|
|
363
|
+
}
|
|
364
|
+
if (hasActiveSessionRequest(request.params.id)
|
|
365
|
+
|| runRegistry.list().some(run => run.sessionId === request.params.id && run.status === 'running')) {
|
|
366
|
+
throw core_1.MarifoldError.agentRunInvalid('Cancel the active request before editing this session history.');
|
|
367
|
+
}
|
|
368
|
+
const result = runtime.truncateSessionFromUserTurn(request.params.id, userTurnIndex);
|
|
369
|
+
if (!result.found) {
|
|
370
|
+
reply.status(404);
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
error: {
|
|
374
|
+
code: 'SESSION_NOT_FOUND',
|
|
375
|
+
message: `Session not found: ${request.params.id}`,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return { ok: true, truncated: result.removedTurns > 0, removedTurns: result.removedTurns };
|
|
380
|
+
});
|
|
381
|
+
// Manually compact a session now (the /compact command): summarize older turns.
|
|
382
|
+
server.post('/v1/sessions/:id/compact', async (request) => {
|
|
383
|
+
const body = (0, Validation_1.objectBody)(request.body);
|
|
384
|
+
const result = await runtime.compactSession(request.params.id, {
|
|
385
|
+
profile: (0, Validation_1.requiredString)(body.profile, 'profile'),
|
|
386
|
+
...(typeof body.provider === 'string' ? { provider: body.provider } : {}),
|
|
387
|
+
...(typeof body.model === 'string' ? { model: body.model } : {}),
|
|
388
|
+
...(typeof body.think === 'boolean' ? { think: body.think } : {}),
|
|
389
|
+
});
|
|
390
|
+
return { ok: true, ...result };
|
|
391
|
+
});
|
|
392
|
+
server.post('/v1/ask', async (request) => {
|
|
393
|
+
const input = parseRunRequest(request.body);
|
|
394
|
+
const endRequest = beginSessionRequest(input.sessionId, input.profile ?? options.loadedConfig.config.default.profile);
|
|
395
|
+
try {
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
response: await runtime.ask(input),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
endRequest();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
server.post('/v1/chat/stream', async (request, reply) => {
|
|
406
|
+
const input = parseRunRequest(request.body);
|
|
407
|
+
const endRequest = beginSessionRequest(input.sessionId, input.profile ?? options.loadedConfig.config.default.profile);
|
|
408
|
+
try {
|
|
409
|
+
await streamChat(reply, runtime, input);
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
endRequest();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
server.get('/v1/schedules', async () => ({
|
|
416
|
+
ok: true,
|
|
417
|
+
schedules: runtime.listSchedules(),
|
|
418
|
+
}));
|
|
419
|
+
server.get('/v1/schedules/:id', async (request, reply) => {
|
|
420
|
+
const schedule = runtime.getSchedule(request.params.id);
|
|
421
|
+
if (!schedule) {
|
|
422
|
+
reply.status(404);
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
error: {
|
|
426
|
+
code: 'SCHEDULE_NOT_FOUND',
|
|
427
|
+
message: `Schedule not found: ${request.params.id}`,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return { ok: true, schedule };
|
|
432
|
+
});
|
|
433
|
+
server.post('/v1/tasks', async (request, reply) => {
|
|
434
|
+
reply.status(201);
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
task: runtime.createTask(parseTaskCreateInput(request.body)),
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
server.get('/v1/tasks', async (request) => ({
|
|
441
|
+
ok: true,
|
|
442
|
+
tasks: runtime.listTasks(parseTaskListOptions(request.query)),
|
|
443
|
+
}));
|
|
444
|
+
server.get('/v1/tasks/:id', async (request, reply) => {
|
|
445
|
+
const task = runtime.getTask(request.params.id);
|
|
446
|
+
if (!task) {
|
|
447
|
+
reply.status(404);
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
error: {
|
|
451
|
+
code: 'TASK_NOT_FOUND',
|
|
452
|
+
message: `Task not found: ${request.params.id}`,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
return { ok: true, task };
|
|
457
|
+
});
|
|
458
|
+
server.patch('/v1/tasks/:id', async (request) => ({
|
|
459
|
+
ok: true,
|
|
460
|
+
task: runtime.updateTask(request.params.id, parseTaskUpdateInput(request.body)),
|
|
461
|
+
}));
|
|
462
|
+
server.post('/v1/tasks/:id/events', async (request) => ({
|
|
463
|
+
ok: true,
|
|
464
|
+
task: runtime.appendTaskEvent(request.params.id, parseTaskEventInput(request.body)),
|
|
465
|
+
}));
|
|
466
|
+
server.delete('/v1/tasks/:id', async (request) => ({
|
|
467
|
+
ok: true,
|
|
468
|
+
deleted: runtime.deleteTask(request.params.id),
|
|
469
|
+
}));
|
|
470
|
+
return server;
|
|
471
|
+
}
|
|
472
|
+
async function startMarifoldService(options) {
|
|
473
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
474
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
475
|
+
const webDir = resolveServiceWebDir(options);
|
|
476
|
+
const server = createMarifoldService({ ...options, host, web: { dir: webDir } });
|
|
477
|
+
try {
|
|
478
|
+
const address = await server.listen({ host, port });
|
|
479
|
+
return {
|
|
480
|
+
server,
|
|
481
|
+
address,
|
|
482
|
+
host,
|
|
483
|
+
port,
|
|
484
|
+
...(webDir ? { webDir } : {}),
|
|
485
|
+
telegram: server.marifoldTelegram,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
// createMarifoldService starts the scheduler/runtime before listen().
|
|
490
|
+
// Always run Fastify's onClose hooks when binding fails, otherwise an
|
|
491
|
+
// EADDRINUSE attempt leaves a ghost process alive on those background
|
|
492
|
+
// handles even though it never served a request.
|
|
493
|
+
try {
|
|
494
|
+
await server.close();
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Preserve the actionable listen error. Close is best-effort here, and
|
|
498
|
+
// individual lifecycle owners also stop from the onClose hook.
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function resolveServiceWebDir(options) {
|
|
504
|
+
return options.web?.dir
|
|
505
|
+
?? options.loadedConfig.config.service?.webDir
|
|
506
|
+
?? (0, StaticRoutes_1.resolveBundledWebDir)();
|
|
507
|
+
}
|
|
508
|
+
async function streamChat(reply, runtime, request) {
|
|
509
|
+
let closed = false;
|
|
510
|
+
let completion;
|
|
511
|
+
// A disconnected client must tear down the in-flight provider request, not
|
|
512
|
+
// just stop the SSE writes — otherwise the model keeps generating unbilled-
|
|
513
|
+
// for output after the browser tab is gone.
|
|
514
|
+
const abort = new AbortController();
|
|
515
|
+
reply.hijack();
|
|
516
|
+
reply.raw.on('close', () => {
|
|
517
|
+
closed = true;
|
|
518
|
+
abort.abort();
|
|
519
|
+
});
|
|
520
|
+
reply.raw.writeHead(200, Sse_1.SSE_HEADERS);
|
|
521
|
+
// No id:/retry: here on purpose — a chat POST is one-shot (an EventSource
|
|
522
|
+
// reconnect would re-run the prompt); only the runs stream is resumable.
|
|
523
|
+
const stopHeartbeat = (0, Sse_1.startSseHeartbeat)(reply);
|
|
524
|
+
try {
|
|
525
|
+
for await (const chunk of runtime.stream({ ...request, signal: abort.signal }, summary => {
|
|
526
|
+
completion = summary;
|
|
527
|
+
}, text => {
|
|
528
|
+
if (!closed)
|
|
529
|
+
(0, Sse_1.writeSse)(reply, 'reasoning', { text });
|
|
530
|
+
})) {
|
|
531
|
+
if (closed)
|
|
532
|
+
break;
|
|
533
|
+
(0, Sse_1.writeSse)(reply, 'chunk', { text: chunk });
|
|
534
|
+
}
|
|
535
|
+
if (!closed) {
|
|
536
|
+
(0, Sse_1.writeSse)(reply, 'done', {
|
|
537
|
+
...(completion?.usage ? { usage: completion.usage } : {}),
|
|
538
|
+
...(completion?.latencyMs !== undefined ? { latencyMs: completion.latencyMs } : {}),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
if (!closed) {
|
|
544
|
+
(0, Sse_1.writeSse)(reply, 'error', normalizeError(error).error);
|
|
545
|
+
(0, Sse_1.writeSse)(reply, 'done', {});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
stopHeartbeat();
|
|
550
|
+
if (!closed)
|
|
551
|
+
reply.raw.end();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function parseRunRequest(value) {
|
|
555
|
+
const body = (0, Validation_1.objectBody)(value);
|
|
556
|
+
return {
|
|
557
|
+
prompt: (0, Validation_1.requiredString)(body.prompt, 'prompt'),
|
|
558
|
+
...(0, Validation_1.optionalStringField)('profile', body.profile),
|
|
559
|
+
...(0, Validation_1.optionalStringField)('provider', body.provider),
|
|
560
|
+
...(0, Validation_1.optionalStringField)('model', body.model),
|
|
561
|
+
...(0, Validation_1.optionalStringField)('sessionId', body.sessionId),
|
|
562
|
+
...(0, Validation_1.optionalStringField)('userTurn', body.userTurn),
|
|
563
|
+
...(0, Validation_1.optionalBooleanField)('isolated', body.isolated),
|
|
564
|
+
...(0, Validation_1.optionalNonNegativeIntegerField)('replaceUserTurnIndex', body.replaceUserTurnIndex),
|
|
565
|
+
...(0, Validation_1.optionalBooleanField)('memories', body.memories),
|
|
566
|
+
...(0, Validation_1.optionalBooleanField)('think', body.think),
|
|
567
|
+
...(0, Validation_1.optionalBooleanField)('profileContext', body.profileContext),
|
|
568
|
+
...(0, Validation_1.optionalBooleanField)('originalImages', body.originalImages),
|
|
569
|
+
...(0, Validation_1.optionalImagesField)(body.images),
|
|
570
|
+
...(body.instructions !== undefined ? { instructions: (0, Validation_1.stringArray)(body.instructions, 'instructions') } : {}),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function parseTaskCreateInput(value) {
|
|
574
|
+
const body = (0, Validation_1.objectBody)(value);
|
|
575
|
+
return {
|
|
576
|
+
objective: (0, Validation_1.requiredString)(body.objective, 'objective'),
|
|
577
|
+
...(0, Validation_1.optionalStringField)('title', body.title),
|
|
578
|
+
...(0, Validation_1.optionalStringField)('profile', body.profile),
|
|
579
|
+
...(0, Validation_1.optionalStringField)('sessionId', body.sessionId),
|
|
580
|
+
...(0, Validation_1.optionalStringField)('summary', body.summary),
|
|
581
|
+
...(0, Validation_1.optionalStringField)('nextAction', body.nextAction),
|
|
582
|
+
...optionalTaskStatusField('status', body.status),
|
|
583
|
+
...optionalTagsField(body.tags),
|
|
584
|
+
...optionalPlanField(body.plan),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function parseTaskUpdateInput(value) {
|
|
588
|
+
const body = (0, Validation_1.objectBody)(value);
|
|
589
|
+
const input = {};
|
|
590
|
+
assignStringIfPresent(input, body, 'title');
|
|
591
|
+
assignStringIfPresent(input, body, 'objective');
|
|
592
|
+
assignStringIfPresent(input, body, 'profile');
|
|
593
|
+
assignStringIfPresent(input, body, 'sessionId');
|
|
594
|
+
assignStringIfPresent(input, body, 'summary');
|
|
595
|
+
assignStringIfPresent(input, body, 'nextAction');
|
|
596
|
+
if (Object.prototype.hasOwnProperty.call(body, 'status'))
|
|
597
|
+
input.status = taskStatus(body.status);
|
|
598
|
+
if (Object.prototype.hasOwnProperty.call(body, 'tags'))
|
|
599
|
+
input.tags = (0, Validation_1.stringArray)(body.tags, 'tags');
|
|
600
|
+
if (Object.prototype.hasOwnProperty.call(body, 'plan'))
|
|
601
|
+
input.plan = planArray(body.plan);
|
|
602
|
+
return input;
|
|
603
|
+
}
|
|
604
|
+
function parseTaskEventInput(value) {
|
|
605
|
+
const body = (0, Validation_1.objectBody)(value);
|
|
606
|
+
return {
|
|
607
|
+
message: (0, Validation_1.requiredString)(body.message, 'message'),
|
|
608
|
+
...(body.kind === undefined ? {} : { kind: taskEventKind(body.kind) }),
|
|
609
|
+
...(0, Validation_1.optionalStringField)('stepId', body.stepId),
|
|
610
|
+
...(body.metadata === undefined ? {} : { metadata: metadataObject(body.metadata) }),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function parseTaskListOptions(query) {
|
|
614
|
+
return {
|
|
615
|
+
...(query.status === undefined ? {} : { status: taskStatus(query.status) }),
|
|
616
|
+
...(query.limit === undefined ? {} : { limit: parseLimitQuery(query.limit) }),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const PROVIDER_TYPES = ['ollama', 'openai-compatible', 'anthropic'];
|
|
620
|
+
function parseProviderTypeField(value) {
|
|
621
|
+
const type = (0, Validation_1.stringValue)(value, 'type');
|
|
622
|
+
const known = PROVIDER_TYPES.find(candidate => candidate === type);
|
|
623
|
+
if (!known)
|
|
624
|
+
throw core_1.MarifoldError.configInvalid(`type must be one of ${PROVIDER_TYPES.join(', ')}.`);
|
|
625
|
+
return known;
|
|
626
|
+
}
|
|
627
|
+
/** The GET /v1/models payload — returned by every model write for refresh-free clients. */
|
|
628
|
+
function modelsView(loadedConfig) {
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
default: {
|
|
632
|
+
provider: loadedConfig.config.default.provider,
|
|
633
|
+
model: loadedConfig.config.default.model,
|
|
634
|
+
},
|
|
635
|
+
options: [...loadedConfig.config.models.options],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function publicConfig(loadedConfig, hasEffectiveToken) {
|
|
639
|
+
const service = loadedConfig.config.service;
|
|
640
|
+
return {
|
|
641
|
+
default: loadedConfig.config.default,
|
|
642
|
+
models: loadedConfig.config.models,
|
|
643
|
+
memory: loadedConfig.config.memory,
|
|
644
|
+
paths: loadedConfig.config.paths,
|
|
645
|
+
// Resolved (defaults merged) and secret-free — clients need the global
|
|
646
|
+
// [agent] to compute a profile's effective permissions.
|
|
647
|
+
agent: (0, core_1.resolveAgentConfig)(loadedConfig.config.agent),
|
|
648
|
+
webSearch: (() => {
|
|
649
|
+
const search = (0, core_1.resolveWebSearchConfig)(loadedConfig.config.webSearch);
|
|
650
|
+
return {
|
|
651
|
+
enabled: search.enabled,
|
|
652
|
+
maxResults: search.maxResults,
|
|
653
|
+
provider: search.provider,
|
|
654
|
+
...(search.apiKeyEnv ? { apiKeyEnv: search.apiKeyEnv } : {}),
|
|
655
|
+
...(search.scrape !== undefined ? { scrape: search.scrape } : {}),
|
|
656
|
+
...(search.proxy ? { proxy: search.proxy } : {}),
|
|
657
|
+
hasApiKey: Boolean(search.apiKey),
|
|
658
|
+
};
|
|
659
|
+
})(),
|
|
660
|
+
// Sanitized [service] view: the token value never leaves the process.
|
|
661
|
+
service: {
|
|
662
|
+
...(service?.webDir ? { webDir: service.webDir } : {}),
|
|
663
|
+
...(service?.tokenEnv ? { tokenEnv: service.tokenEnv } : {}),
|
|
664
|
+
corsOrigins: service?.corsOrigins ?? [],
|
|
665
|
+
hasToken: hasEffectiveToken,
|
|
666
|
+
},
|
|
667
|
+
providers: Object.fromEntries(Object.entries(loadedConfig.config.providers)
|
|
668
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
669
|
+
.map(([name, provider]) => [name, publicProvider(provider)])),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function skillHint(skill) {
|
|
673
|
+
const vars = skill.variables
|
|
674
|
+
.map(variable => (variable.required ? `<${variable.name}>` : `[${variable.name}]`))
|
|
675
|
+
.join(' ');
|
|
676
|
+
return {
|
|
677
|
+
name: skill.name,
|
|
678
|
+
description: skill.description,
|
|
679
|
+
usage: `$${skill.name}${vars ? ` ${vars}` : ''}`,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function publicProvider(provider) {
|
|
683
|
+
return {
|
|
684
|
+
type: provider.type,
|
|
685
|
+
...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
|
|
686
|
+
...(provider.apiKeyEnv ? { apiKeyEnv: provider.apiKeyEnv } : {}),
|
|
687
|
+
// proxy is a non-secret URL like baseUrl, so it crosses the wire in the
|
|
688
|
+
// clear (unlike api_key). A proxy URL *can* embed credentials
|
|
689
|
+
// (user:pass@host); that's the caller's choice, same as a secret in baseUrl.
|
|
690
|
+
...(provider.proxy ? { proxy: provider.proxy } : {}),
|
|
691
|
+
hasApiKey: Boolean(provider.apiKey),
|
|
692
|
+
hasOauthToken: Boolean(provider.oauthToken),
|
|
693
|
+
hasApiKeyExpiresAt: provider.apiKeyExpiresAt !== undefined,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function normalizeError(error) {
|
|
697
|
+
if (error instanceof core_1.MarifoldError) {
|
|
698
|
+
return {
|
|
699
|
+
statusCode: statusCodeForError(error),
|
|
700
|
+
error: {
|
|
701
|
+
code: error.code,
|
|
702
|
+
message: error.message,
|
|
703
|
+
...(Object.keys(error.details).length > 0 ? { details: error.details } : {}),
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (error instanceof Error) {
|
|
708
|
+
return {
|
|
709
|
+
statusCode: 500,
|
|
710
|
+
error: {
|
|
711
|
+
code: 'INTERNAL_ERROR',
|
|
712
|
+
message: error.message,
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
statusCode: 500,
|
|
718
|
+
error: {
|
|
719
|
+
code: 'INTERNAL_ERROR',
|
|
720
|
+
message: String(error),
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function statusCodeForError(error) {
|
|
725
|
+
if (error.code === 'TASK_NOT_FOUND'
|
|
726
|
+
|| error.code === 'SCHEDULE_NOT_FOUND'
|
|
727
|
+
|| error.code === 'SKILL_NOT_FOUND'
|
|
728
|
+
|| error.code === 'APP_NOT_FOUND'
|
|
729
|
+
|| error.code === 'RUN_NOT_FOUND'
|
|
730
|
+
|| error.code === 'APPROVAL_NOT_FOUND'
|
|
731
|
+
|| error.code === 'USER_INPUT_NOT_FOUND') {
|
|
732
|
+
return 404;
|
|
733
|
+
}
|
|
734
|
+
if (error.code === 'CONFIG_INVALID'
|
|
735
|
+
|| error.code === 'IMAGE_INVALID'
|
|
736
|
+
|| error.code === 'PROFILE_INVALID'
|
|
737
|
+
|| error.code === 'MEMORY_INVALID'
|
|
738
|
+
|| error.code === 'TASK_INVALID'
|
|
739
|
+
|| error.code === 'SCHEDULE_INVALID'
|
|
740
|
+
|| error.code === 'AGENT_TOOL_INVALID'
|
|
741
|
+
|| error.code === 'AGENT_RUN_INVALID'
|
|
742
|
+
|| error.code === 'SKILL_INVALID'
|
|
743
|
+
|| error.code === 'APP_INVALID') {
|
|
744
|
+
return 400;
|
|
745
|
+
}
|
|
746
|
+
if (error.code === 'CONFIG_FILE_NOT_FOUND')
|
|
747
|
+
return 404;
|
|
748
|
+
if (error.code === 'UNAUTHORIZED')
|
|
749
|
+
return 401;
|
|
750
|
+
if (error.code === 'NETWORK_FORBIDDEN' || error.code === 'ORIGIN_FORBIDDEN')
|
|
751
|
+
return 403;
|
|
752
|
+
if (error.code === 'RUN_LIMIT_EXCEEDED')
|
|
753
|
+
return 429;
|
|
754
|
+
if (error.code === 'PROVIDER_ERROR')
|
|
755
|
+
return 502;
|
|
756
|
+
return 500;
|
|
757
|
+
}
|
|
758
|
+
function optionalTaskStatusField(key, value) {
|
|
759
|
+
if (value === undefined)
|
|
760
|
+
return {};
|
|
761
|
+
return { [key]: taskStatus(value) };
|
|
762
|
+
}
|
|
763
|
+
function optionalTagsField(value) {
|
|
764
|
+
if (value === undefined)
|
|
765
|
+
return {};
|
|
766
|
+
return { tags: (0, Validation_1.stringArray)(value, 'tags') };
|
|
767
|
+
}
|
|
768
|
+
function optionalPlanField(value) {
|
|
769
|
+
if (value === undefined)
|
|
770
|
+
return {};
|
|
771
|
+
return { plan: planArray(value) };
|
|
772
|
+
}
|
|
773
|
+
function assignStringIfPresent(input, body, key) {
|
|
774
|
+
if (!Object.prototype.hasOwnProperty.call(body, key))
|
|
775
|
+
return;
|
|
776
|
+
const value = body[key];
|
|
777
|
+
if (value === undefined)
|
|
778
|
+
return;
|
|
779
|
+
if (value === null) {
|
|
780
|
+
input[key] = '';
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
input[key] = (0, Validation_1.stringValue)(value, key);
|
|
784
|
+
}
|
|
785
|
+
function planArray(value) {
|
|
786
|
+
if (!Array.isArray(value))
|
|
787
|
+
throw core_1.MarifoldError.configInvalid('plan must be an array.');
|
|
788
|
+
return value.map((item, index) => {
|
|
789
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
790
|
+
throw core_1.MarifoldError.configInvalid(`plan[${index}] must be an object.`);
|
|
791
|
+
}
|
|
792
|
+
const step = item;
|
|
793
|
+
return {
|
|
794
|
+
text: (0, Validation_1.requiredString)(step.text, `plan[${index}].text`),
|
|
795
|
+
...(0, Validation_1.optionalStringField)('id', step.id),
|
|
796
|
+
...(step.status === undefined ? {} : { status: stepStatus(step.status) }),
|
|
797
|
+
};
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
function metadataObject(value) {
|
|
801
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
802
|
+
throw core_1.MarifoldError.configInvalid('metadata must be an object.');
|
|
803
|
+
}
|
|
804
|
+
const metadata = {};
|
|
805
|
+
for (const [key, item] of Object.entries(value)) {
|
|
806
|
+
metadata[key] = (0, Validation_1.stringValue)(item, `metadata.${key}`);
|
|
807
|
+
}
|
|
808
|
+
return metadata;
|
|
809
|
+
}
|
|
810
|
+
function parseLimitQuery(value) {
|
|
811
|
+
if (value === undefined)
|
|
812
|
+
return undefined;
|
|
813
|
+
const parsed = Number.parseInt(value, 10);
|
|
814
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
815
|
+
throw core_1.MarifoldError.configInvalid('limit must be a positive integer.');
|
|
816
|
+
}
|
|
817
|
+
return parsed;
|
|
818
|
+
}
|
|
819
|
+
function nonNegativeIntegerPath(value, name) {
|
|
820
|
+
const parsed = Number(value);
|
|
821
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
822
|
+
throw core_1.MarifoldError.configInvalid(`${name} must be a non-negative integer.`);
|
|
823
|
+
}
|
|
824
|
+
return parsed;
|
|
825
|
+
}
|
|
826
|
+
function parseBooleanQuery(value) {
|
|
827
|
+
if (value === undefined)
|
|
828
|
+
return false;
|
|
829
|
+
const normalized = value.trim().toLowerCase();
|
|
830
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'yes')
|
|
831
|
+
return true;
|
|
832
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'no')
|
|
833
|
+
return false;
|
|
834
|
+
throw core_1.MarifoldError.configInvalid('Boolean query values must be true or false.');
|
|
835
|
+
}
|
|
836
|
+
function taskStatus(value) {
|
|
837
|
+
if (value === 'running' || value === 'blocked' || value === 'completed' || value === 'failed' || value === 'cancelled')
|
|
838
|
+
return value;
|
|
839
|
+
throw core_1.MarifoldError.configInvalid(`Invalid task status '${String(value)}'.`);
|
|
840
|
+
}
|
|
841
|
+
function stepStatus(value) {
|
|
842
|
+
if (value === 'pending' || value === 'in_progress' || value === 'completed' || value === 'skipped' || value === 'cancelled')
|
|
843
|
+
return value;
|
|
844
|
+
throw core_1.MarifoldError.configInvalid(`Invalid task step status '${String(value)}'.`);
|
|
845
|
+
}
|
|
846
|
+
function taskEventKind(value) {
|
|
847
|
+
if (value === 'progress' || value === 'decision' || value === 'observation' || value === 'blocker' || value === 'verification' || value === 'note')
|
|
848
|
+
return value;
|
|
849
|
+
throw core_1.MarifoldError.configInvalid(`Invalid task event kind '${String(value)}'.`);
|
|
850
|
+
}
|
|
851
|
+
//# sourceMappingURL=MarifoldService.js.map
|