@pellux/goodvibes-daemon 1.28.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/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.drafts.* handler surface (Channel Drafts / Draft Sync Backend).
|
|
3
|
+
*
|
|
4
|
+
* Attaches host handlers to the four SDK-registered builtin descriptors:
|
|
5
|
+
* channels.drafts.list read:channels read-only
|
|
6
|
+
* channels.drafts.get read:channels read-only
|
|
7
|
+
* channels.drafts.save write:channels access:admin, confirm-gated
|
|
8
|
+
* channels.drafts.delete write:channels access:admin, dangerous, confirm-gated
|
|
9
|
+
*
|
|
10
|
+
* The SDK owns every id, descriptor, access flag, and I/O schema (auto-
|
|
11
|
+
* registered with handler:undefined). This module NEVER re-declares any of
|
|
12
|
+
* them — it looks each up via catalog.get(id) (inside registerCatalogHandler)
|
|
13
|
+
* and re-registers with the wrapped handler. save/delete are confirm-gated via
|
|
14
|
+
* RegisterHandlerOptions.confirm; the wrapper enforces body.confirm === true
|
|
15
|
+
* AND context.explicitUserRequest === true before the handler runs.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: the message body is encrypted at rest and NEVER returned (the wire
|
|
18
|
+
* `message` field carries the sha256First(body,12) digest). Webhooks are
|
|
19
|
+
* encrypted at rest and ALWAYS redacted on read; RAW webhook tokens submitted
|
|
20
|
+
* to save are REJECTED — the agent must redact ('[redacted]') or pass a
|
|
21
|
+
* goodvibes://secrets/ reference before transmission.
|
|
22
|
+
*/
|
|
23
|
+
import type { HandlerContext } from '../context.ts';
|
|
24
|
+
import { createAtRestCipher } from '../credentials.ts';
|
|
25
|
+
import { HandlerError } from '../errors.ts';
|
|
26
|
+
import { registerCatalogHandlers } from '../register.ts';
|
|
27
|
+
import type { TypedHandler, Unregister } from '../register.ts';
|
|
28
|
+
import {
|
|
29
|
+
ALL_DRAFT_STATUSES,
|
|
30
|
+
DraftSyncStore,
|
|
31
|
+
MAX_DRAFT_LIST_LIMIT,
|
|
32
|
+
WRITABLE_DRAFT_STATUSES,
|
|
33
|
+
} from './draft-store.ts';
|
|
34
|
+
import type {
|
|
35
|
+
DraftListQuery,
|
|
36
|
+
DraftRecord,
|
|
37
|
+
DraftSaveInput,
|
|
38
|
+
DraftStatus,
|
|
39
|
+
} from './draft-store.ts';
|
|
40
|
+
|
|
41
|
+
const BAD_INPUT = 'INVALID_INPUT';
|
|
42
|
+
|
|
43
|
+
const LIST_ID = 'channels.drafts.list';
|
|
44
|
+
const GET_ID = 'channels.drafts.get';
|
|
45
|
+
const SAVE_ID = 'channels.drafts.save';
|
|
46
|
+
const DELETE_ID = 'channels.drafts.delete';
|
|
47
|
+
|
|
48
|
+
const writableStatusSet = new Set<string>(WRITABLE_DRAFT_STATUSES);
|
|
49
|
+
const allStatusSet = new Set<string>(ALL_DRAFT_STATUSES);
|
|
50
|
+
|
|
51
|
+
// --- Input validation helpers ----------------------------------------------
|
|
52
|
+
|
|
53
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
54
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
55
|
+
throw new HandlerError('Request body must be an object.', BAD_INPUT, 400);
|
|
56
|
+
}
|
|
57
|
+
return body as Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
61
|
+
if (value === undefined || value === null) return undefined;
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new HandlerError(`Field '${field}' must be a string.`, BAD_INPUT, 400);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requiredString(value: unknown, field: string): string {
|
|
69
|
+
const str = optionalString(value, field);
|
|
70
|
+
if (str === undefined || str.length === 0) {
|
|
71
|
+
throw new HandlerError(`Field '${field}' is required.`, BAD_INPUT, 400);
|
|
72
|
+
}
|
|
73
|
+
return str;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function optionalTags(value: unknown): string[] | undefined {
|
|
77
|
+
if (value === undefined || value === null) return undefined;
|
|
78
|
+
if (!Array.isArray(value)) {
|
|
79
|
+
throw new HandlerError("Field 'tags' must be an array of strings.", BAD_INPUT, 400);
|
|
80
|
+
}
|
|
81
|
+
const tags: string[] = [];
|
|
82
|
+
for (const entry of value) {
|
|
83
|
+
if (typeof entry !== 'string') {
|
|
84
|
+
throw new HandlerError("Field 'tags' must contain only strings.", BAD_INPUT, 400);
|
|
85
|
+
}
|
|
86
|
+
tags.push(entry);
|
|
87
|
+
}
|
|
88
|
+
return tags;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function optionalLimit(value: unknown): number | undefined {
|
|
92
|
+
if (value === undefined || value === null) return undefined;
|
|
93
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
94
|
+
throw new HandlerError("Field 'limit' must be an integer.", BAD_INPUT, 400);
|
|
95
|
+
}
|
|
96
|
+
if (value < 1 || value > MAX_DRAFT_LIST_LIMIT) {
|
|
97
|
+
throw new HandlerError(
|
|
98
|
+
`Field 'limit' must be between 1 and ${MAX_DRAFT_LIST_LIMIT}.`,
|
|
99
|
+
BAD_INPUT,
|
|
100
|
+
400,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function optionalVersion(value: unknown): number | undefined {
|
|
107
|
+
if (value === undefined || value === null) return undefined;
|
|
108
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
109
|
+
throw new HandlerError("Field 'version' must be a positive integer.", BAD_INPUT, 400);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strict ISO-8601 date-time. Anchored so loosely-formatted but Date.parse-able
|
|
115
|
+
// values (e.g. '2020', 'Jan 1 2020', '2020-1-1') are rejected.
|
|
116
|
+
const ISO_8601_DATE_TIME =
|
|
117
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
118
|
+
|
|
119
|
+
function optionalIso8601(value: unknown, field: string): string | undefined {
|
|
120
|
+
const str = optionalString(value, field);
|
|
121
|
+
if (str === undefined) return undefined;
|
|
122
|
+
const time = ISO_8601_DATE_TIME.test(str) ? Date.parse(str) : Number.NaN;
|
|
123
|
+
if (Number.isNaN(time)) {
|
|
124
|
+
throw new HandlerError(`Field '${field}' must be an ISO-8601 timestamp.`, BAD_INPUT, 400);
|
|
125
|
+
}
|
|
126
|
+
return new Date(time).toISOString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reject RAW webhook tokens submitted to save. The contract requires webhooks
|
|
131
|
+
* to be redacted before transmission; the daemon only stores '[redacted]' or a
|
|
132
|
+
* goodvibes://secrets/ reference (which carries no token material). Any other
|
|
133
|
+
* value bearing a scheme (e.g. an https:// URL with an embedded token) is a raw
|
|
134
|
+
* secret and is rejected outright — never persisted, never logged.
|
|
135
|
+
*/
|
|
136
|
+
function validateWebhook(value: string | undefined): string | undefined {
|
|
137
|
+
if (value === undefined) return undefined;
|
|
138
|
+
if (value === '' || value === '[redacted]') return value;
|
|
139
|
+
if (value.startsWith('goodvibes://secrets/')) return value;
|
|
140
|
+
if (value.includes('://')) {
|
|
141
|
+
throw new HandlerError(
|
|
142
|
+
"Field 'webhook' must be redacted before transmission: pass '[redacted]' "
|
|
143
|
+
+ 'or a goodvibes://secrets/ reference, never a raw webhook URL.',
|
|
144
|
+
'WEBHOOK_REDACTION_REQUIRED',
|
|
145
|
+
400,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseListStatus(value: unknown): DraftStatus | undefined {
|
|
152
|
+
const str = optionalString(value, 'status');
|
|
153
|
+
if (str === undefined) return undefined;
|
|
154
|
+
if (!allStatusSet.has(str)) {
|
|
155
|
+
throw new HandlerError(
|
|
156
|
+
`Field 'status' must be one of: ${ALL_DRAFT_STATUSES.join(', ')}.`,
|
|
157
|
+
BAD_INPUT,
|
|
158
|
+
400,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return str as DraftStatus;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseSaveStatus(value: unknown): DraftStatus | undefined {
|
|
165
|
+
const str = optionalString(value, 'status');
|
|
166
|
+
if (str === undefined) return undefined;
|
|
167
|
+
if (!writableStatusSet.has(str)) {
|
|
168
|
+
throw new HandlerError(
|
|
169
|
+
`Field 'status' must be one of: ${WRITABLE_DRAFT_STATUSES.join(', ')}.`,
|
|
170
|
+
BAD_INPUT,
|
|
171
|
+
400,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return str as DraftStatus;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseListQuery(body: unknown): DraftListQuery {
|
|
178
|
+
const record = asRecord(body ?? {});
|
|
179
|
+
const query: DraftListQuery = {};
|
|
180
|
+
const status = parseListStatus(record.status);
|
|
181
|
+
if (status !== undefined) query.status = status;
|
|
182
|
+
const limit = optionalLimit(record.limit);
|
|
183
|
+
if (limit !== undefined) query.limit = limit;
|
|
184
|
+
return query;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseSaveInput(body: unknown): DraftSaveInput {
|
|
188
|
+
const record = asRecord(body);
|
|
189
|
+
const input: DraftSaveInput = {
|
|
190
|
+
message: requiredString(record.message, 'message'),
|
|
191
|
+
};
|
|
192
|
+
const id = optionalString(record.id, 'id');
|
|
193
|
+
if (id !== undefined) input.id = id;
|
|
194
|
+
const version = optionalVersion(record.version);
|
|
195
|
+
if (version !== undefined) input.version = version;
|
|
196
|
+
const title = optionalString(record.title, 'title');
|
|
197
|
+
if (title !== undefined) input.title = title;
|
|
198
|
+
const channel = optionalString(record.channel, 'channel');
|
|
199
|
+
if (channel !== undefined) input.channel = channel;
|
|
200
|
+
const route = optionalString(record.route, 'route');
|
|
201
|
+
if (route !== undefined) input.route = route;
|
|
202
|
+
const webhook = validateWebhook(optionalString(record.webhook, 'webhook'));
|
|
203
|
+
if (webhook !== undefined) input.webhook = webhook;
|
|
204
|
+
const link = optionalString(record.link, 'link');
|
|
205
|
+
if (link !== undefined) input.link = link;
|
|
206
|
+
const tags = optionalTags(record.tags);
|
|
207
|
+
if (tags !== undefined) input.tags = tags;
|
|
208
|
+
const status = parseSaveStatus(record.status);
|
|
209
|
+
if (status !== undefined) input.status = status;
|
|
210
|
+
const createdAt = optionalIso8601(record.createdAt, 'createdAt');
|
|
211
|
+
if (createdAt !== undefined) input.createdAt = createdAt;
|
|
212
|
+
const updatedAt = optionalIso8601(record.updatedAt, 'updatedAt');
|
|
213
|
+
if (updatedAt !== undefined) input.updatedAt = updatedAt;
|
|
214
|
+
return input;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Both get and delete identify a draft by `draftId` (per the SDK input schema). */
|
|
218
|
+
function parseDraftId(body: unknown): string {
|
|
219
|
+
const record = asRecord(body);
|
|
220
|
+
return requiredString(record.draftId, 'draftId');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Response shapes (match the SDK output schemas) -------------------------
|
|
224
|
+
|
|
225
|
+
interface ListResponse {
|
|
226
|
+
drafts: DraftRecord[];
|
|
227
|
+
total: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** get: a flat draft (CHANNEL_DRAFT_GET_OUTPUT permits additionalProperties) or a notFound marker. */
|
|
231
|
+
type GetResponse = (DraftRecord & { messageDigest: string }) | { notFound: true; id: string };
|
|
232
|
+
|
|
233
|
+
interface SaveResponse {
|
|
234
|
+
draft: DraftRecord;
|
|
235
|
+
created: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface DeleteResponse {
|
|
239
|
+
deleted: boolean;
|
|
240
|
+
draftId: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface RegisterDraftsOptions {
|
|
244
|
+
/** Override the sqlite filename (tests). */
|
|
245
|
+
fileName?: string;
|
|
246
|
+
/** Inject a pre-built store (tests). When provided, the caller owns its lifecycle. */
|
|
247
|
+
store?: DraftSyncStore;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register the channels.drafts.* handlers against the catalog held by `ctx`.
|
|
252
|
+
* Returns an Unregister that removes all four handlers (and closes the store
|
|
253
|
+
* when this function owns it).
|
|
254
|
+
*
|
|
255
|
+
* The store is created eagerly but initialized lazily on first invocation (the
|
|
256
|
+
* SQLite WASM load is deferred until a draft method is actually called). Every
|
|
257
|
+
* mutating handler persists the store via save() after the mutation so the
|
|
258
|
+
* mirror survives daemon restarts.
|
|
259
|
+
*/
|
|
260
|
+
export function registerDraftMethods(
|
|
261
|
+
ctx: HandlerContext,
|
|
262
|
+
options: RegisterDraftsOptions = {},
|
|
263
|
+
): Unregister {
|
|
264
|
+
const store =
|
|
265
|
+
options.store
|
|
266
|
+
?? new DraftSyncStore({
|
|
267
|
+
workingDirectory: ctx.workingDirectory,
|
|
268
|
+
cipher: createAtRestCipher(ctx.credentials),
|
|
269
|
+
...(options.fileName !== undefined ? { fileName: options.fileName } : {}),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
let initPromise: Promise<void> | null = null;
|
|
273
|
+
const ensureInit = (): Promise<void> => {
|
|
274
|
+
if (!initPromise) initPromise = store.init();
|
|
275
|
+
return initPromise;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const listHandler: TypedHandler<unknown, ListResponse> = async ({ body }) => {
|
|
279
|
+
await ensureInit();
|
|
280
|
+
const query = parseListQuery(body);
|
|
281
|
+
const drafts = store.list(query);
|
|
282
|
+
return { drafts, total: store.count(query.status) };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const getHandler: TypedHandler<unknown, GetResponse> = async ({ body }) => {
|
|
286
|
+
await ensureInit();
|
|
287
|
+
const id = parseDraftId(body);
|
|
288
|
+
const record = store.get(id);
|
|
289
|
+
if (record === null) return { notFound: true, id };
|
|
290
|
+
// get output permits additional properties — expose messageDigest explicitly
|
|
291
|
+
// alongside the schema-required `message` field (both carry the digest).
|
|
292
|
+
return { ...record, messageDigest: record.message };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const saveHandler: TypedHandler<unknown, SaveResponse> = async ({ body }) => {
|
|
296
|
+
await ensureInit();
|
|
297
|
+
const input = parseSaveInput(body);
|
|
298
|
+
const result = await store.upsert(input);
|
|
299
|
+
await store.save();
|
|
300
|
+
return result;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const deleteHandler: TypedHandler<unknown, DeleteResponse> = async ({ body }) => {
|
|
304
|
+
await ensureInit();
|
|
305
|
+
const draftId = parseDraftId(body);
|
|
306
|
+
const deleted = store.delete(draftId);
|
|
307
|
+
if (deleted) await store.save();
|
|
308
|
+
return { deleted, draftId };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const unregister = registerCatalogHandlers(ctx.catalog, [
|
|
312
|
+
{ id: LIST_ID, handler: listHandler as TypedHandler<unknown, unknown> },
|
|
313
|
+
{ id: GET_ID, handler: getHandler as TypedHandler<unknown, unknown> },
|
|
314
|
+
{
|
|
315
|
+
id: SAVE_ID,
|
|
316
|
+
handler: saveHandler as TypedHandler<unknown, unknown>,
|
|
317
|
+
options: { confirm: true },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: DELETE_ID,
|
|
321
|
+
handler: deleteHandler as TypedHandler<unknown, unknown>,
|
|
322
|
+
options: { confirm: true },
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const ownsStore = options.store === undefined;
|
|
327
|
+
return () => {
|
|
328
|
+
unregister();
|
|
329
|
+
if (ownsStore) store.close();
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type for daemon handler execution. Carries a stable machine `code`
|
|
3
|
+
* and an HTTP `status` so the control-plane dispatcher can map failures to
|
|
4
|
+
* wire responses without leaking implementation detail.
|
|
5
|
+
*/
|
|
6
|
+
export class HandlerError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly code: string,
|
|
10
|
+
public readonly status: number,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'HandlerError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Code emitted when a confirmation-gated method is invoked without explicit user confirmation. */
|
|
18
|
+
export const REQUIRE_CONFIRM = 'REQUIRE_CONFIRM';
|