@pellux/goodvibes-tui 0.25.0 → 0.26.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +5 -3
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
  6. package/src/daemon/handlers/calendar/index.ts +316 -0
  7. package/src/daemon/handlers/context.ts +29 -0
  8. package/src/daemon/handlers/contracts.ts +77 -0
  9. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  10. package/src/daemon/handlers/drafts/index.ts +17 -0
  11. package/src/daemon/handlers/drafts/register.ts +331 -0
  12. package/src/daemon/handlers/email/config.ts +164 -0
  13. package/src/daemon/handlers/email/index.ts +43 -0
  14. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  15. package/src/daemon/handlers/email/runtime.ts +140 -0
  16. package/src/daemon/handlers/email/validation.ts +147 -0
  17. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  18. package/src/daemon/handlers/errors.ts +18 -0
  19. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  20. package/src/daemon/handlers/inbox/index.ts +210 -0
  21. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  22. package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
  23. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  24. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
  25. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  26. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
  27. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
  28. package/src/daemon/handlers/index.ts +107 -0
  29. package/src/daemon/handlers/register.ts +161 -0
  30. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  31. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  32. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  33. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  34. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  35. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  36. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  37. package/src/daemon/handlers/remote/index.ts +119 -0
  38. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  39. package/src/daemon/handlers/remote/service.ts +191 -0
  40. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  41. package/src/daemon/handlers/routing/index.ts +261 -0
  42. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  43. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  44. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  45. package/src/daemon/handlers/triage/index.ts +57 -0
  46. package/src/daemon/handlers/triage/integration.ts +212 -0
  47. package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
  48. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  49. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  50. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  51. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  52. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  53. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  54. package/src/daemon/handlers/triage/types.ts +50 -0
  55. package/src/runtime/services.ts +48 -35
  56. package/src/version.ts +1 -1
  57. package/src/daemon/calendar/index.ts +0 -52
  58. package/src/daemon/calendar/register.ts +0 -527
  59. package/src/daemon/channels/drafts/index.ts +0 -22
  60. package/src/daemon/channels/drafts/register.ts +0 -449
  61. package/src/daemon/channels/inbox/index.ts +0 -58
  62. package/src/daemon/channels/inbox/register.ts +0 -247
  63. package/src/daemon/channels/routing/index.ts +0 -39
  64. package/src/daemon/channels/routing/register.ts +0 -296
  65. package/src/daemon/email/index.ts +0 -68
  66. package/src/daemon/email/register.ts +0 -715
  67. package/src/daemon/operator/index.ts +0 -43
  68. package/src/daemon/operator/register-helper.ts +0 -150
  69. package/src/daemon/operator/surfaces.ts +0 -137
  70. package/src/daemon/operator/types.ts +0 -207
  71. package/src/daemon/remote/backends/index.ts +0 -34
  72. package/src/daemon/remote/index.ts +0 -74
  73. package/src/daemon/remote/register.ts +0 -411
  74. package/src/daemon/triage/index.ts +0 -59
  75. package/src/daemon/triage/integration.ts +0 -179
  76. package/src/daemon/triage/register.ts +0 -231
  77. package/src/daemon/triage/tagger.ts +0 -777
  78. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  79. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  80. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  81. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  82. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  83. /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
  84. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.33.38",
102
+ "@pellux/goodvibes-sdk": "0.34.0",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -1,8 +1,8 @@
1
- // CalDAV client for the calendar operator surface.
1
+ // CalDAV client for the calendar handler surface.
2
2
  //
3
3
  // Authenticates to the user's CalDAV endpoint using credentials resolved from
4
4
  // the daemon credential store (config keys under `surfaces.calendar.*`). It
5
- // implements the CalDAV/WebDAV verbs the operator methods need:
5
+ // implements the CalDAV/WebDAV verbs the gateway methods need:
6
6
  // - REPORT (calendar-query) -> list / get events
7
7
  // - PUT -> create / import events
8
8
  // - REPORT (multiget) / GET -> export a collection
@@ -16,9 +16,9 @@
16
16
  // * Event hrefs are returned to callers as opaque relative identifiers, never
17
17
  // as fully-qualified authenticated URLs.
18
18
 
19
- import type { OperatorContext } from '../operator/types.ts';
20
- import { OperatorError } from '../operator/types.ts';
21
- import { createDaemonCredentialStore } from '../operator/credential-store.ts';
19
+ import { HandlerError } from '../errors.ts';
20
+ import type { HandlerContext } from '../context.ts';
21
+ import type { DaemonCredentialStore } from '../credentials.ts';
22
22
  import {
23
23
  generateCalendar,
24
24
  generateICS,
@@ -112,12 +112,15 @@ export interface CalDavClient {
112
112
  exportIcs(opts: { calendarId?: string; from?: string; to?: string }): Promise<ExportResult>;
113
113
  }
114
114
 
115
+ /** Subset of the handler context the CalDAV config resolver needs. */
116
+ export type CalDavConfigContext = Pick<HandlerContext, 'configManager' | 'credentials'>;
117
+
115
118
  // ---------------------------------------------------------------------------
116
119
  // Config resolution
117
120
  // ---------------------------------------------------------------------------
118
121
 
119
122
  function readConfigString(
120
- ctx: Pick<OperatorContext, 'configManager'>,
123
+ ctx: Pick<HandlerContext, 'configManager'>,
121
124
  key: string,
122
125
  ): string | undefined {
123
126
  const value = ctx.configManager.get(key as never);
@@ -147,16 +150,17 @@ function parseCollectionMap(raw: string | undefined): Record<string, string> {
147
150
  * Resolve the full CalDAV configuration from config + credential store.
148
151
  * The password is resolved from the daemon credential store — either as a
149
152
  * goodvibes://secrets/ reference stored in config, or the config-key-derived
150
- * secret. Throws OperatorError when the surface is not configured.
153
+ * secret. Throws HandlerError when the surface is not configured. The resolved
154
+ * password is held only in memory and never returned to callers.
151
155
  */
152
156
  export async function resolveCalDavConfig(
153
- ctx: Pick<OperatorContext, 'configManager' | 'secrets'>,
157
+ ctx: CalDavConfigContext,
154
158
  ): Promise<CalDavConfig> {
155
- const credentials = createDaemonCredentialStore(ctx.secrets);
159
+ const credentials: DaemonCredentialStore = ctx.credentials;
156
160
  const baseUrl = readConfigString(ctx, CFG_URL);
157
161
  const username = readConfigString(ctx, CFG_USER);
158
162
  if (!baseUrl || !username) {
159
- throw new OperatorError(
163
+ throw new HandlerError(
160
164
  'CalDAV is not configured. Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser.',
161
165
  'CALENDAR_NOT_CONFIGURED',
162
166
  412,
@@ -174,7 +178,7 @@ export async function resolveCalDavConfig(
174
178
  password = await credentials.resolveConfigSecret(CFG_PASSWORD);
175
179
  }
176
180
  if (!password) {
177
- throw new OperatorError(
181
+ throw new HandlerError(
178
182
  'CalDAV password is not available in the credential store.',
179
183
  'CALENDAR_CREDENTIALS_MISSING',
180
184
  412,
@@ -204,7 +208,7 @@ function joinUrl(base: string, segment: string): string {
204
208
  /** Extract the scheme+host origin from an absolute URL (no trailing slash). */
205
209
  export function originOf(url: string): string {
206
210
  const match = /^(https?:\/\/[^/]+)/i.exec(url.trim());
207
- return match ? match[1] : stripTrailingSlash(url);
211
+ return match ? match[1]! : stripTrailingSlash(url);
208
212
  }
209
213
 
210
214
  /**
@@ -227,7 +231,7 @@ function resolveCollectionUrl(baseUrl: string, path: string): string {
227
231
  export function toRelativeHref(href: string): string {
228
232
  const trimmed = href.trim();
229
233
  const schemeMatch = /^https?:\/\/[^/]+(\/.*)$/i.exec(trimmed);
230
- if (schemeMatch) return schemeMatch[1];
234
+ if (schemeMatch) return schemeMatch[1]!;
231
235
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
232
236
  }
233
237
 
@@ -302,7 +306,7 @@ function propfindCalendarsBody(): string {
302
306
  function toCalDavStamp(iso: string): string {
303
307
  const date = new Date(iso);
304
308
  if (Number.isNaN(date.getTime())) {
305
- throw new OperatorError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
309
+ throw new HandlerError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
306
310
  }
307
311
  const pad = (n: number): string => (n < 10 ? `0${n}` : String(n));
308
312
  return (
@@ -353,7 +357,7 @@ export function parseMultiStatus(xml: string): MultiStatusEntry[] {
353
357
  const responseRe = /<(?:[a-zA-Z0-9]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi;
354
358
  let match: RegExpExecArray | null;
355
359
  while ((match = responseRe.exec(xml)) !== null) {
356
- const block = match[1];
360
+ const block = match[1]!;
357
361
  const hrefRaw = tagContent(block, 'href');
358
362
  if (!hrefRaw) continue;
359
363
  const href = decodeXmlEntities(hrefRaw.trim());
@@ -425,18 +429,18 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
425
429
  // (e.g. "getaddrinfo ENOTFOUND cal.example.com") leaks the CalDAV
426
430
  // hostname/URL into caller-visible output (importIcs errors[]).
427
431
  // Never include creds/URL/host or raw fetch detail.
428
- throw new OperatorError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
432
+ throw new HandlerError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
429
433
  }
430
434
  const text = await response.text();
431
435
  if (!response.ok) {
432
- // Map auth/permission/not-found to operator errors. Never include creds/URL.
436
+ // Map auth/permission/not-found to handler errors. Never include creds/URL.
433
437
  const status = response.status;
434
438
  const code = status === 401 || status === 403
435
439
  ? 'CALENDAR_AUTH_FAILED'
436
440
  : status === 404
437
441
  ? 'CALENDAR_NOT_FOUND'
438
442
  : 'CALENDAR_REQUEST_FAILED';
439
- throw new OperatorError(
443
+ throw new HandlerError(
440
444
  `CalDAV server returned HTTP ${status}.`,
441
445
  code,
442
446
  status >= 400 && status < 500 ? status : 502,
@@ -472,7 +476,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
472
476
  const rel = toRelativeHref(entry.href);
473
477
  // Derive a stable logical id from the last path segment.
474
478
  const segments = rel.split('/').filter((s) => s.length > 0);
475
- const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : config.defaultCalendarId;
479
+ const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]!) : config.defaultCalendarId;
476
480
  if (seen.has(logical)) continue;
477
481
  seen.add(logical);
478
482
  calendars.push({ calendarId: logical, displayName: entry.displayName ?? logical });
@@ -509,7 +513,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
509
513
  single = await request(resourceUrl, 'GET', undefined, { Depth: '0' });
510
514
  } catch (error) {
511
515
  // A 404 maps to "not found"; any other failure propagates.
512
- if (error instanceof OperatorError && error.code === 'CALENDAR_NOT_FOUND') {
516
+ if (error instanceof HandlerError && error.code === 'CALENDAR_NOT_FOUND') {
513
517
  return null;
514
518
  }
515
519
  throw error;
@@ -517,7 +521,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
517
521
  const parsed = parseICS(single.text);
518
522
  if (parsed.length === 0) return null;
519
523
  const relHref = toRelativeHref(eventId);
520
- return { ...parsed[0], href: relHref, calendarId: id };
524
+ return { ...parsed[0]!, href: relHref, calendarId: id };
521
525
  }
522
526
 
523
527
  // Strategy 2 — bare UID: ask the server for ONLY the matching resource via
@@ -566,7 +570,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
566
570
  const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
567
571
  const parsed = parseICS(icsContent);
568
572
  if (parsed.length === 0) {
569
- throw new OperatorError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
573
+ throw new HandlerError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
570
574
  }
571
575
  const collectionUrl = collectionUrlFor(id);
572
576
  const eventIds: string[] = [];
@@ -582,7 +586,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
582
586
  eventIds.push(toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath)));
583
587
  imported += 1;
584
588
  } catch (error) {
585
- const message = error instanceof OperatorError ? error.message : error instanceof Error ? error.message : String(error);
589
+ const message = error instanceof HandlerError ? error.message : error instanceof Error ? error.message : String(error);
586
590
  errors.push(`${uid}: ${message}`);
587
591
  }
588
592
  }
@@ -646,7 +650,7 @@ function parsedToGenerateInput(event: ParsedICalEvent, uid: string): GenerateICa
646
650
  description: event.description,
647
651
  location: event.location,
648
652
  // Re-emit attendees by their raw value so import preserves the original
649
- // addressing; display-name-only redaction applies to operator responses,
653
+ // addressing; display-name-only redaction applies to handler responses,
650
654
  // not to server-side payloads.
651
655
  attendees: event.attendees.map((a) => a.rawValue),
652
656
  organizer: event.organizerRaw,
@@ -0,0 +1,316 @@
1
+ // Handler registration for the CalDAV calendar surface.
2
+ //
3
+ // Attaches host handlers to the five SDK-registered calendar gateway method
4
+ // descriptors BY ID (the SDK auto-registers them with handler:undefined). No
5
+ // method id, descriptor, or schema is authored here — `registerCatalogHandlers`
6
+ // looks up the canonical descriptor via `catalog.get(id)` and re-registers it
7
+ // with the wrapped handler. Method IDs handled (exactly):
8
+ // calendar.events.list read:calendar no confirm
9
+ // calendar.events.get read:calendar no confirm
10
+ // calendar.events.create write:calendar confirm:true + explicitUserRequest
11
+ // calendar.ics.import write:calendar confirm:true + explicitUserRequest
12
+ // calendar.ics.export read:calendar no confirm
13
+ //
14
+ // SECURITY: CalDAV credentials and authenticated URLs NEVER appear in any
15
+ // response. `calendarId` is a logical id. Attendees are surfaced as display
16
+ // names only (no raw addresses). Outputs are stripped to exactly the SDK output
17
+ // schema fields (the SDK schemas set additionalProperties:false).
18
+
19
+ import { HandlerError } from '../errors.ts';
20
+ import type { HandlerContext } from '../context.ts';
21
+ import {
22
+ registerCatalogHandlers,
23
+ type CatalogHandlerEntry,
24
+ type TypedHandler,
25
+ type Unregister,
26
+ } from '../register.ts';
27
+ import {
28
+ createCalDavClient,
29
+ resolveCalDavConfig,
30
+ type CalDavClient,
31
+ type CalDavConfigContext,
32
+ type CalDavEvent,
33
+ } from './caldav-client.ts';
34
+
35
+ /**
36
+ * The exact set of method IDs this surface publishes — a single source of truth
37
+ * for the calendar capability set, consumed by integration wiring and tests.
38
+ */
39
+ export const CALENDAR_METHOD_IDS = [
40
+ 'calendar.events.list',
41
+ 'calendar.events.get',
42
+ 'calendar.events.create',
43
+ 'calendar.ics.import',
44
+ 'calendar.ics.export',
45
+ ] as const;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Client factory injection (real client by default; tests inject a stub).
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export type CalDavClientFactory = (ctx: CalDavConfigContext) => Promise<CalDavClient>;
52
+
53
+ const realClientFactory: CalDavClientFactory = async (ctx) => {
54
+ const config = await resolveCalDavConfig(ctx);
55
+ return createCalDavClient({ config });
56
+ };
57
+
58
+ export interface RegisterCalendarOptions {
59
+ /** Override the CalDAV client factory (for tests). */
60
+ clientFactory?: CalDavClientFactory;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Input validation helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function asRecord(body: unknown): Record<string, unknown> {
68
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
69
+ throw new HandlerError('Request body must be an object.', 'CALENDAR_BAD_INPUT', 400);
70
+ }
71
+ return body as Record<string, unknown>;
72
+ }
73
+
74
+ function optionalString(record: Record<string, unknown>, key: string): string | undefined {
75
+ const value = record[key];
76
+ if (value === undefined || value === null) return undefined;
77
+ if (typeof value !== 'string') {
78
+ throw new HandlerError(`Field '${key}' must be a string.`, 'CALENDAR_BAD_INPUT', 400);
79
+ }
80
+ const trimmed = value.trim();
81
+ return trimmed.length > 0 ? trimmed : undefined;
82
+ }
83
+
84
+ function requiredString(record: Record<string, unknown>, key: string): string {
85
+ const value = optionalString(record, key);
86
+ if (value === undefined) {
87
+ throw new HandlerError(`Field '${key}' is required.`, 'CALENDAR_BAD_INPUT', 400);
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
93
+ const value = record[key];
94
+ if (value === undefined || value === null) return undefined;
95
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
96
+ throw new HandlerError(`Field '${key}' must be a number.`, 'CALENDAR_BAD_INPUT', 400);
97
+ }
98
+ return value;
99
+ }
100
+
101
+ function optionalStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
102
+ const value = record[key];
103
+ if (value === undefined || value === null) return undefined;
104
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
105
+ throw new HandlerError(`Field '${key}' must be an array of strings.`, 'CALENDAR_BAD_INPUT', 400);
106
+ }
107
+ return (value as string[]).map((item) => item.trim()).filter((item) => item.length > 0);
108
+ }
109
+
110
+ function validateIsoDate(value: string, field: string): string {
111
+ if (Number.isNaN(new Date(value).getTime())) {
112
+ throw new HandlerError(`Field '${field}' must be a valid ISO-8601 date.`, 'CALENDAR_BAD_INPUT', 400);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Response mapping (credential-free, PII-stripped, schema-exact)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Wire shape for `calendar.events.list` items — EXACTLY the SDK
123
+ * CALENDAR_EVENT_SUMMARY_SCHEMA (additionalProperties:false). Optional fields
124
+ * are present only when populated. `calendarId`/`allDay`/organizer are NOT in
125
+ * the SDK schema and are intentionally dropped.
126
+ */
127
+ export interface CalendarEventSummary {
128
+ id: string;
129
+ title: string;
130
+ start: string;
131
+ end: string;
132
+ location?: string;
133
+ description?: string;
134
+ attendees?: string[];
135
+ }
136
+
137
+ function displayAttendees(event: CalDavEvent): string[] {
138
+ return event.attendees.map((a) => a.displayName).filter((name) => name.length > 0);
139
+ }
140
+
141
+ function toSummary(event: CalDavEvent): CalendarEventSummary {
142
+ const attendees = displayAttendees(event);
143
+ const summary: CalendarEventSummary = {
144
+ id: event.href || event.uid,
145
+ title: event.summary,
146
+ start: event.start,
147
+ end: event.end,
148
+ };
149
+ if (event.location !== undefined) summary.location = event.location;
150
+ if (event.description !== undefined) summary.description = event.description;
151
+ if (attendees.length > 0) summary.attendees = attendees;
152
+ return summary;
153
+ }
154
+
155
+ /**
156
+ * Wire shape for `calendar.events.get` — EXACTLY the SDK
157
+ * CALENDAR_EVENT_DETAIL_SCHEMA (additionalProperties:false), returned directly
158
+ * (not wrapped). `uid` carries the raw iCalendar UID; attendees are display
159
+ * names only.
160
+ */
161
+ export interface CalendarEventDetail {
162
+ id: string;
163
+ uid: string;
164
+ title: string;
165
+ start: string;
166
+ end: string;
167
+ location?: string;
168
+ description?: string;
169
+ attendees?: string[];
170
+ recurrence?: string;
171
+ }
172
+
173
+ function toDetail(event: CalDavEvent): CalendarEventDetail {
174
+ const attendees = displayAttendees(event);
175
+ const detail: CalendarEventDetail = {
176
+ id: event.href || event.uid,
177
+ uid: event.uid,
178
+ title: event.summary,
179
+ start: event.start,
180
+ end: event.end,
181
+ };
182
+ if (event.location !== undefined) detail.location = event.location;
183
+ if (event.description !== undefined) detail.description = event.description;
184
+ if (attendees.length > 0) detail.attendees = attendees;
185
+ if (event.recurrence !== undefined) detail.recurrence = event.recurrence;
186
+ return detail;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Handler bodies
191
+ // ---------------------------------------------------------------------------
192
+
193
+ interface ListBody {
194
+ calendarId?: string;
195
+ from?: string;
196
+ to?: string;
197
+ limit?: number;
198
+ }
199
+
200
+ interface GetBody {
201
+ eventId: string;
202
+ calendarId?: string;
203
+ }
204
+
205
+ interface ExportBody {
206
+ calendarId?: string;
207
+ from?: string;
208
+ to?: string;
209
+ }
210
+
211
+ /**
212
+ * Register all five calendar handlers against the SDK catalog held by `ctx`.
213
+ * Returns a single Unregister that detaches them in reverse order.
214
+ */
215
+ export function registerCalendarMethods(
216
+ ctx: HandlerContext,
217
+ options: RegisterCalendarOptions = {},
218
+ ): Unregister {
219
+ const clientFactory = options.clientFactory ?? realClientFactory;
220
+ const getClient = (): Promise<CalDavClient> => clientFactory(ctx);
221
+
222
+ const listHandler: TypedHandler<unknown, { events: CalendarEventSummary[] }> = async ({ body }) => {
223
+ const record = body === undefined || body === null ? {} : asRecord(body);
224
+ const input: ListBody = {
225
+ calendarId: optionalString(record, 'calendarId'),
226
+ from: optionalString(record, 'from'),
227
+ to: optionalString(record, 'to'),
228
+ limit: optionalNumber(record, 'limit'),
229
+ };
230
+ if (input.from) validateIsoDate(input.from, 'from');
231
+ if (input.to) validateIsoDate(input.to, 'to');
232
+ const limit = input.limit !== undefined ? Math.max(1, Math.min(200, Math.floor(input.limit))) : 20;
233
+ const client = await getClient();
234
+ const events = await client.listEvents({ ...input, limit });
235
+ return { events: events.map(toSummary) };
236
+ };
237
+
238
+ const getHandler: TypedHandler<unknown, CalendarEventDetail> = async ({ body }) => {
239
+ const record = asRecord(body);
240
+ const input: GetBody = {
241
+ eventId: requiredString(record, 'eventId'),
242
+ calendarId: optionalString(record, 'calendarId'),
243
+ };
244
+ const client = await getClient();
245
+ const event = await client.getEvent(input.eventId, input.calendarId);
246
+ if (!event) {
247
+ throw new HandlerError(`Event not found: ${input.eventId}`, 'CALENDAR_NOT_FOUND', 404);
248
+ }
249
+ return toDetail(event);
250
+ };
251
+
252
+ const createHandler: TypedHandler<unknown, { eventId: string; uid: string; createdAt: string }> = async ({ body }) => {
253
+ const record = asRecord(body);
254
+ const title = requiredString(record, 'title');
255
+ const start = validateIsoDate(requiredString(record, 'start'), 'start');
256
+ const end = validateIsoDate(requiredString(record, 'end'), 'end');
257
+ if (new Date(end).getTime() < new Date(start).getTime()) {
258
+ throw new HandlerError("Field 'end' must not be before 'start'.", 'CALENDAR_BAD_INPUT', 400);
259
+ }
260
+ const client = await getClient();
261
+ const created = await client.createEvent({
262
+ title,
263
+ start,
264
+ end,
265
+ description: optionalString(record, 'description'),
266
+ attendees: optionalStringArray(record, 'attendees'),
267
+ location: optionalString(record, 'location'),
268
+ calendarId: optionalString(record, 'calendarId'),
269
+ });
270
+ return { eventId: created.eventId, uid: created.uid, createdAt: created.createdAt };
271
+ };
272
+
273
+ const importHandler: TypedHandler<unknown, { imported: number; eventIds: string[]; errors: string[] }> = async ({ body }) => {
274
+ const record = asRecord(body);
275
+ const icsContent = requiredString(record, 'icsContent');
276
+ const calendarId = optionalString(record, 'calendarId');
277
+ const client = await getClient();
278
+ return client.importIcs(icsContent, calendarId);
279
+ };
280
+
281
+ const exportHandler: TypedHandler<unknown, { icsContent: string; eventCount: number }> = async ({ body }) => {
282
+ const record = body === undefined || body === null ? {} : asRecord(body);
283
+ const input: ExportBody = {
284
+ calendarId: optionalString(record, 'calendarId'),
285
+ from: optionalString(record, 'from'),
286
+ to: optionalString(record, 'to'),
287
+ };
288
+ if (input.from) validateIsoDate(input.from, 'from');
289
+ if (input.to) validateIsoDate(input.to, 'to');
290
+ const client = await getClient();
291
+ return client.exportIcs(input);
292
+ };
293
+
294
+ const entries: CatalogHandlerEntry[] = [
295
+ { id: 'calendar.events.list', handler: listHandler as TypedHandler<unknown, unknown> },
296
+ { id: 'calendar.events.get', handler: getHandler as TypedHandler<unknown, unknown> },
297
+ {
298
+ id: 'calendar.events.create',
299
+ handler: createHandler as TypedHandler<unknown, unknown>,
300
+ options: { confirm: true },
301
+ },
302
+ {
303
+ id: 'calendar.ics.import',
304
+ handler: importHandler as TypedHandler<unknown, unknown>,
305
+ options: { confirm: true },
306
+ },
307
+ { id: 'calendar.ics.export', handler: exportHandler as TypedHandler<unknown, unknown> },
308
+ ];
309
+
310
+ return registerCatalogHandlers(ctx.catalog, entries);
311
+ }
312
+
313
+ /** SurfaceRegister-compatible entry point used by the daemon composition root. */
314
+ export function registerCalendar(ctx: HandlerContext): Unregister {
315
+ return registerCalendarMethods(ctx);
316
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Host-facing context passed to every surface register function. Replaces the
3
+ * former OperatorContext. It carries the SDK gateway catalog (handlers attach
4
+ * to it), the daemon credential store, a read-only slice of the config manager,
5
+ * resolved directories, and a logger. No SDK descriptor or schema is declared
6
+ * here — the catalog type is re-exported through the contracts seam.
7
+ */
8
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
+ import type { GatewayMethodCatalog } from './contracts.ts';
10
+ import type { DaemonCredentialStore } from './credentials.ts';
11
+ import type { Unregister } from './register.ts';
12
+
13
+ export interface HandlerLogger {
14
+ info(message: string, meta?: unknown): void;
15
+ warn(message: string, meta?: unknown): void;
16
+ error(message: string, meta?: unknown): void;
17
+ }
18
+
19
+ export interface HandlerContext {
20
+ readonly catalog: GatewayMethodCatalog;
21
+ readonly credentials: DaemonCredentialStore;
22
+ readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
23
+ readonly workingDirectory: string;
24
+ readonly homeDirectory: string;
25
+ readonly logger: HandlerLogger;
26
+ }
27
+
28
+ /** Every surface module exports a register function of this shape. */
29
+ export type SurfaceRegister = (ctx: HandlerContext) => Unregister;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Single SDK-contract import seam for the daemon handler layer.
3
+ *
4
+ * Every other module under `src/daemon/handlers/` imports SDK contract
5
+ * identifiers from HERE and nowhere else. Concentrating the SDK imports in one
6
+ * concrete-submodule module keeps the rest of the layer free of barrel cycles
7
+ * and guarantees the host NEVER re-declares an SDK id, descriptor, or schema —
8
+ * it only attaches handlers to the descriptors the SDK already registered.
9
+ */
10
+
11
+ // Catalog + invocation contract types (concrete control-plane subpath, not a project barrel).
12
+ export type {
13
+ GatewayMethodCatalog,
14
+ GatewayMethodDescriptor,
15
+ GatewayMethodInvocation,
16
+ GatewayMethodInvocationContext,
17
+ GatewayMethodHandler,
18
+ } from '@pellux/goodvibes-sdk/platform/control-plane';
19
+
20
+ // Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
21
+ export type {
22
+ ChannelIdentity,
23
+ ChannelResolvedTarget,
24
+ ChannelAccountRecord,
25
+ } from '@pellux/goodvibes-sdk/platform/channels';
26
+
27
+ /**
28
+ * Per-peer authentication envelope passed to peer-scoped remote routes.
29
+ *
30
+ * The daemon-sdk ships `RemotePeerAuth` only as an UNEXPORTED local alias
31
+ * inside `@pellux/goodvibes-daemon-sdk/remote-routes` (it is `unknown` there),
32
+ * so it cannot be re-exported. We mirror that exact shape here for the host
33
+ * implementation. This is an implementable runtime contract, not a method
34
+ * descriptor or schema — declaring it does not violate the no-re-declaration
35
+ * rule for catalog methods.
36
+ */
37
+ export type RemotePeerAuth = unknown;
38
+
39
+ /**
40
+ * Remote distributed-runtime route service the HOST must implement and supply
41
+ * as `RuntimeServices.distributedRuntime`. The SDK facade injects the instance
42
+ * into `DaemonRemoteRouteContext.distributedRuntime` so the published
43
+ * `remote.peers.*` HTTP routes can dispatch to it.
44
+ *
45
+ * The daemon-sdk declares this interface locally in
46
+ * `@pellux/goodvibes-daemon-sdk/remote-routes` but does NOT export it (the
47
+ * module ends with `export {}`), so it cannot be imported. This declaration
48
+ * mirrors the SDK's exact structural shape (17 methods, verbatim signatures)
49
+ * so a host implementation is assignable to the SDK's context field. The SDK
50
+ * ships no docker/ssh/cloud backend — the host owns the implementation.
51
+ */
52
+ export interface DistributedRuntimeRouteService {
53
+ listPairRequests(): unknown;
54
+ approvePairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null>;
55
+ rejectPairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null>;
56
+ listPeers(): unknown;
57
+ rotatePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
58
+ revokePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
59
+ disconnectPeer(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
60
+ listWork(): unknown;
61
+ invokePeer(input: Record<string, unknown>): Promise<unknown>;
62
+ cancelWork(workId: string, input: Record<string, unknown>): Promise<unknown | null>;
63
+ getNodeHostContract(): unknown;
64
+ requestPairing(input: Record<string, unknown>): Promise<unknown>;
65
+ verifyPairRequest(
66
+ requestId: string,
67
+ challenge: string,
68
+ input: Record<string, unknown>,
69
+ ): Promise<unknown | null>;
70
+ heartbeatPeer(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown>;
71
+ claimWork(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown>;
72
+ completeWork(
73
+ auth: RemotePeerAuth,
74
+ workId: string,
75
+ input: Record<string, unknown>,
76
+ ): Promise<unknown | null>;
77
+ }