@pikku/core 0.12.137 → 0.12.139

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 (78) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/dev/hot-reload.js +5 -2
  3. package/dist/env.d.ts +18 -0
  4. package/dist/env.js +60 -1
  5. package/dist/function/abort-scope.d.ts +6 -5
  6. package/dist/function/abort-scope.js +6 -17
  7. package/dist/function/function-runner.js +2 -1
  8. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  9. package/dist/middleware/addon-scope-cap.js +34 -0
  10. package/dist/middleware/auth-apikey.js +1 -1
  11. package/dist/middleware/auth-bearer.js +1 -1
  12. package/dist/middleware/auth-cookie.js +1 -1
  13. package/dist/middleware/index.d.ts +1 -0
  14. package/dist/middleware/index.js +1 -0
  15. package/dist/schema.js +3 -1
  16. package/dist/scopes.d.ts +8 -0
  17. package/dist/scopes.js +23 -0
  18. package/dist/services/content-service.d.ts +13 -1
  19. package/dist/services/in-memory-queue-service.js +4 -2
  20. package/dist/services/in-memory-workflow-service.js +7 -6
  21. package/dist/services/index.d.ts +2 -2
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.js +21 -60
  24. package/dist/services/local-content.d.ts +45 -1
  25. package/dist/services/local-content.js +202 -18
  26. package/dist/services/local-variables.d.ts +5 -0
  27. package/dist/services/local-variables.js +7 -2
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-content-service.d.ts +35 -0
  30. package/dist/services/scoped-content-service.js +171 -0
  31. package/dist/services/webhook-service.d.ts +2 -2
  32. package/dist/services/webhook-service.js +6 -4
  33. package/dist/types/core.types.d.ts +10 -1
  34. package/dist/types/state.types.d.ts +8 -0
  35. package/dist/utils/hmac.d.ts +25 -10
  36. package/dist/utils/hmac.js +202 -24
  37. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  38. package/dist/wirings/addon/addon-reach.js +21 -0
  39. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  40. package/dist/wirings/addon/addon-runner.js +13 -0
  41. package/dist/wirings/addon/define-screens.d.ts +32 -0
  42. package/dist/wirings/addon/define-screens.js +5 -0
  43. package/dist/wirings/addon/index.d.ts +2 -0
  44. package/dist/wirings/addon/index.js +1 -0
  45. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  46. package/dist/wirings/addon/wire-addon.js +4 -0
  47. package/dist/wirings/agent/agent-agui.js +65 -62
  48. package/dist/wirings/agent/agent-interrupt.d.ts +4 -1
  49. package/dist/wirings/agent/agent-interrupt.js +3 -3
  50. package/dist/wirings/agent/agent-memory.js +5 -6
  51. package/dist/wirings/agent/agent-model-config.js +2 -1
  52. package/dist/wirings/agent/agent-prepare.js +24 -13
  53. package/dist/wirings/agent/agent-stream.js +38 -34
  54. package/dist/wirings/agent/agent.types.d.ts +8 -1
  55. package/dist/wirings/channel/channel-handler.js +1 -1
  56. package/dist/wirings/channel/local/local-channel-handler.js +5 -1
  57. package/dist/wirings/channel/local/local-channel-runner.js +6 -4
  58. package/dist/wirings/channel/local/local-eventhub-service.d.ts +1 -0
  59. package/dist/wirings/channel/local/local-eventhub-service.js +4 -1
  60. package/dist/wirings/cli/channel/cli-channel-runner.js +7 -2
  61. package/dist/wirings/cli/channel/cli-raw-client-runner.js +8 -4
  62. package/dist/wirings/cli/cli-runner.js +1 -1
  63. package/dist/wirings/gateway/gateway-runner.js +3 -3
  64. package/dist/wirings/http/http-runner.js +6 -6
  65. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  66. package/dist/wirings/rpc/rpc-runner.js +41 -2
  67. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  68. package/dist/wirings/scheduler/scheduler-runner.js +1 -1
  69. package/dist/wirings/scope/define-scope.d.ts +19 -0
  70. package/dist/wirings/scope/define-scope.js +19 -0
  71. package/dist/wirings/scope/index.d.ts +2 -2
  72. package/dist/wirings/scope/index.js +2 -2
  73. package/dist/wirings/trigger/trigger-runner.js +3 -1
  74. package/dist/wirings/trigger/webhook-source-runner.js +5 -2
  75. package/dist/wirings/virtual-user/virtual-user-scaffold.js +3 -2
  76. package/dist/wirings/workflow/workflow-invocation-id.js +85 -8
  77. package/package.json +3 -1
  78. package/src/public-surface.json +65 -18
@@ -86,7 +86,7 @@ function createPersistingChannel(parent, storage, threadId, logger) {
86
86
  flush: flushStep,
87
87
  close: () => parent.close(),
88
88
  sendBinary: (data) => parent.sendBinary(data),
89
- send: (event) => {
89
+ send: async (event) => {
90
90
  // Accumulated whether or not storage is configured: `fullText` is what
91
91
  // the client was streamed, and an interrupted run has to be able to
92
92
  // report the fragment it got through even with persistence turned off.
@@ -149,7 +149,7 @@ function createPersistingChannel(parent, storage, threadId, logger) {
149
149
  break;
150
150
  }
151
151
  }
152
- parent.send(event);
152
+ await parent.send(event);
153
153
  },
154
154
  setState: (s) => parent.setState(s),
155
155
  getState: () => parent.getState(),
@@ -267,7 +267,7 @@ async function runStreamStepLoop(params) {
267
267
  if (stopped)
268
268
  break;
269
269
  }
270
- channel.send({ type: 'step-start', stepNumber: step });
270
+ await channel.send({ type: 'step-start', stepNumber: step });
271
271
  const stepResult = await agentRunner.stream(runnerParams, streamChannel);
272
272
  for (const mw of agentMiddlewares) {
273
273
  if (mw.afterStep) {
@@ -422,10 +422,10 @@ function handleApprovals(approvals, runId, channel, agentRunState, persistingCha
422
422
  reason: err.reason,
423
423
  runId,
424
424
  };
425
- channel.send(approvalEvent);
425
+ await channel.send(approvalEvent);
426
426
  }
427
- channel.send({ type: 'done' });
428
- channel.close();
427
+ await channel.send({ type: 'done' });
428
+ await channel.close();
429
429
  })();
430
430
  }
431
431
  function handleCredentialRequests(requests, runId, channel, agentRunState, persistingChannel) {
@@ -446,7 +446,7 @@ function handleCredentialRequests(requests, runId, channel, agentRunState, persi
446
446
  pendingApprovals,
447
447
  });
448
448
  for (const req of requests) {
449
- channel.send({
449
+ await channel.send({
450
450
  type: 'credential-request',
451
451
  toolCallId: req.toolCallId,
452
452
  toolName: req.toolName,
@@ -457,8 +457,8 @@ function handleCredentialRequests(requests, runId, channel, agentRunState, persi
457
457
  runId,
458
458
  });
459
459
  }
460
- channel.send({ type: 'done' });
461
- channel.close();
460
+ await channel.send({ type: 'done' });
461
+ await channel.close();
462
462
  })();
463
463
  }
464
464
  export async function streamAgent(agentName, input, channel, params, agentSessionMap, options) {
@@ -487,8 +487,12 @@ export async function streamAgent(agentName, input, channel, params, agentSessio
487
487
  createdAt: new Date(),
488
488
  updatedAt: new Date(),
489
489
  });
490
- channel.send({ type: 'suspended', reason: 'rpc-missing', missingRpcs });
491
- channel.send({ type: 'done' });
490
+ await channel.send({
491
+ type: 'suspended',
492
+ reason: 'rpc-missing',
493
+ missingRpcs,
494
+ });
495
+ await channel.send({ type: 'done' });
492
496
  return '';
493
497
  }
494
498
  const workingMemoryMiddleware = getWorkingMemoryMiddleware(memoryConfig, storage, {
@@ -512,7 +516,7 @@ export async function streamAgent(agentName, input, channel, params, agentSessio
512
516
  // knowledge: decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md
513
517
  const transcript = sharedNotes[SPOKEN_TRANSCRIPT];
514
518
  if (typeof transcript === 'string') {
515
- channel.send({ type: 'transcript', text: transcript });
519
+ await channel.send({ type: 'transcript', text: transcript });
516
520
  }
517
521
  // knowledge: decisions/internals/thread-history-records-the-transcript-not-the-audio.md
518
522
  const lastModified = modifiedMessages[modifiedMessages.length - 1];
@@ -615,7 +619,7 @@ export async function streamAgent(agentName, input, channel, params, agentSessio
615
619
  });
616
620
  // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
617
621
  await outputChannel.send({ type: 'done' });
618
- channel.close();
622
+ await channel.close();
619
623
  return persistingChannel.fullText;
620
624
  }
621
625
  catch (err) {
@@ -631,14 +635,14 @@ export async function streamAgent(agentName, input, channel, params, agentSessio
631
635
  if (storage) {
632
636
  trackInterruptNote(threadId, persistOrphanedToolResults(interruptHandle, storage, threadId));
633
637
  }
634
- channel.send({
638
+ await channel.send({
635
639
  type: 'interrupted',
636
640
  runId,
637
641
  text: persistingChannel.fullText,
638
642
  reason: interruption?.reason ?? 'user',
639
643
  });
640
- channel.send({ type: 'done' });
641
- channel.close();
644
+ await channel.send({ type: 'done' });
645
+ await channel.close();
642
646
  return persistingChannel.fullText;
643
647
  }
644
648
  for (const mw of agentMiddlewares) {
@@ -657,12 +661,12 @@ export async function streamAgent(agentName, input, channel, params, agentSessio
657
661
  status: 'failed',
658
662
  errorMessage: err instanceof Error ? err.message : String(err),
659
663
  });
660
- channel.send({
664
+ await channel.send({
661
665
  type: 'error',
662
666
  message: err instanceof Error ? err.message : String(err),
663
667
  });
664
- channel.send({ type: 'done' });
665
- channel.close();
668
+ await channel.send({ type: 'done' });
669
+ await channel.close();
666
670
  return persistingChannel.fullText;
667
671
  }
668
672
  finally {
@@ -763,7 +767,7 @@ export async function resumeAgent(input, channel, params, options) {
763
767
  },
764
768
  ]);
765
769
  }
766
- channel.send({
770
+ await channel.send({
767
771
  type: 'tool-result',
768
772
  toolCallId: input.toolCallId,
769
773
  toolName: pending.type === 'tool-call' || pending.type === 'credential-request'
@@ -774,8 +778,8 @@ export async function resumeAgent(input, channel, params, options) {
774
778
  const updatedRun = await agentRunState.getRun(run.runId);
775
779
  const remaining = updatedRun?.pendingApprovals ?? [];
776
780
  if (remaining.length > 0) {
777
- channel.send({ type: 'done' });
778
- channel.close();
781
+ await channel.send({ type: 'done' });
782
+ await channel.close();
779
783
  return;
780
784
  }
781
785
  await agentRunState.updateRun(run.runId, { status: 'running' });
@@ -793,7 +797,7 @@ export async function resumeAgent(input, channel, params, options) {
793
797
  throw new Error(`No pending approval on sub-agent run ${pending.agentRunId}`);
794
798
  }
795
799
  const subChannel = createScopedChannel(channel, subRun.agentName, 'resume');
796
- channel.send({
800
+ await channel.send({
797
801
  type: 'agent-call',
798
802
  agentName: subRun.agentName,
799
803
  session: 'resume',
@@ -804,7 +808,7 @@ export async function resumeAgent(input, channel, params, options) {
804
808
  toolCallId: subPending.toolCallId,
805
809
  approved: true,
806
810
  }, subChannel, params, options);
807
- channel.send({
811
+ await channel.send({
808
812
  type: 'agent-result',
809
813
  agentName: subRun.agentName,
810
814
  session: 'resume',
@@ -871,7 +875,7 @@ export async function resumeAgent(input, channel, params, options) {
871
875
  },
872
876
  ]);
873
877
  }
874
- channel.send({
878
+ await channel.send({
875
879
  type: 'tool-result',
876
880
  toolCallId: input.toolCallId,
877
881
  toolName: pending.toolName,
@@ -882,8 +886,8 @@ export async function resumeAgent(input, channel, params, options) {
882
886
  const updatedRun = await agentRunState.getRun(run.runId);
883
887
  const remaining = updatedRun?.pendingApprovals ?? [];
884
888
  if (remaining.length > 0) {
885
- channel.send({ type: 'done' });
886
- channel.close();
889
+ await channel.send({ type: 'done' });
890
+ await channel.close();
887
891
  return;
888
892
  }
889
893
  await agentRunState.updateRun(run.runId, { status: 'running' });
@@ -1002,7 +1006,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
1002
1006
  });
1003
1007
  // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
1004
1008
  await wrappedChannel.send({ type: 'done' });
1005
- channel.close();
1009
+ await channel.close();
1006
1010
  }
1007
1011
  catch (err) {
1008
1012
  // Same reasoning as the first turn: an interrupt is not a failure, and the
@@ -1014,14 +1018,14 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
1014
1018
  if (storage) {
1015
1019
  trackInterruptNote(run.threadId, persistOrphanedToolResults(interruptHandle, storage, run.threadId));
1016
1020
  }
1017
- channel.send({
1021
+ await channel.send({
1018
1022
  type: 'interrupted',
1019
1023
  runId: run.runId,
1020
1024
  text: persistingChannel.fullText,
1021
1025
  reason: interruption?.reason ?? 'user',
1022
1026
  });
1023
- channel.send({ type: 'done' });
1024
- channel.close();
1027
+ await channel.send({ type: 'done' });
1028
+ await channel.close();
1025
1029
  return;
1026
1030
  }
1027
1031
  for (const mw of agentMiddlewares) {
@@ -1040,12 +1044,12 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
1040
1044
  status: 'failed',
1041
1045
  errorMessage: err instanceof Error ? err.message : String(err),
1042
1046
  });
1043
- channel.send({
1047
+ await channel.send({
1044
1048
  type: 'error',
1045
1049
  message: err instanceof Error ? err.message : String(err),
1046
1050
  });
1047
- channel.send({ type: 'done' });
1048
- channel.close();
1051
+ await channel.send({ type: 'done' });
1052
+ await channel.close();
1049
1053
  }
1050
1054
  finally {
1051
1055
  interruptHandle.release();
@@ -1,5 +1,6 @@
1
1
  import type { CorePermissionGroup, CorePikkuPermission } from '../../function/functions.types.js';
2
2
  import type { CorePikkuMiddleware, MiddlewareMetadata } from '../../middleware/middleware.types.js';
3
+ import type { AbortScope } from '../../function/abort-scope.js';
3
4
  import type { CoreSingletonServices } from '../../types/core.types.js';
4
5
  import type { PermissionMetadata } from '../../function/function-meta.types.js';
5
6
  import type { AIProviderOptions } from '../../services/agent-runner-service.js';
@@ -166,7 +167,13 @@ export interface AgentToolDef extends Partial<ApprovalPolicy> {
166
167
  name: string;
167
168
  description: string;
168
169
  inputSchema: Record<string, unknown>;
169
- execute: (input: unknown) => Promise<unknown>;
170
+ /**
171
+ * `options.abortScope` is supplied by the interrupt tracker and must be put
172
+ * on the wire of whatever this runs, so `beginChanges()` can observe it.
173
+ */
174
+ execute: (input: unknown, options?: {
175
+ abortScope?: AbortScope;
176
+ }) => Promise<unknown>;
170
177
  /**
171
178
  * Mirrors the pikku function's `readonly` flag. A read that gets interrupted
172
179
  * is discarded rather than reported: nothing changed, and by the next turn
@@ -47,7 +47,7 @@ export const processMessageHandlers = (services, channelConfig, channelHandler,
47
47
  : 'the default message route';
48
48
  logger.error(`Channel ${channelConfig.name} with id ${channelHandler.getChannel().channelId} requires a session for ${routeMessage}. No session is attached to this websocket connection. Ensure auth middleware establishes a session during websocket upgrade, and configure sessionStore when the runtime needs persisted sessions.`);
49
49
  // knowledge: questions/unauthorized-channel-replies-escape-the-declared-out-type.md
50
- channelHandler.getChannel().send(`Unauthorized for ${routeMessage}`);
50
+ await channelHandler.getChannel().send(`Unauthorized for ${routeMessage}`);
51
51
  return;
52
52
  }
53
53
  const routeMeta = getRouteMeta(channelConfig.name, routingProperty, routerValue);
@@ -14,6 +14,10 @@ export class PikkuLocalChannelHandler extends PikkuAbstractChannelHandler {
14
14
  registerOnOpen(callback) {
15
15
  this.openCallBack = callback;
16
16
  }
17
+ // The base class declares `open(): void`; this override is intentionally
18
+ // async (callers already await it). Widening the exported base signature is
19
+ // a public API change, so the rule is suppressed for this one override.
20
+ // oxlint-disable-next-line typescript/no-misused-promises
17
21
  async open() {
18
22
  this.getChannel().state = 'open';
19
23
  if (this.openCallBack) {
@@ -39,7 +43,7 @@ export class PikkuLocalChannelHandler extends PikkuAbstractChannelHandler {
39
43
  if (this.getChannel().state === 'closed') {
40
44
  return;
41
45
  }
42
- super.close();
46
+ await super.close();
43
47
  const results = await Promise.allSettled(this.closeCallbacks.map((cb) => cb()));
44
48
  for (const result of results) {
45
49
  if (result.status === 'rejected') {
@@ -98,7 +98,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
98
98
  catch (e) {
99
99
  singletonServices.logger.error(`Error handling onConnect: ${e}`);
100
100
  const errorResponse = getErrorResponse(e);
101
- channel.send({
101
+ await channel.send({
102
102
  error: errorResponse?.message ??
103
103
  (isProduction() ? 'Internal server error' : e.message),
104
104
  ...(!isProduction() && { errorName: e.constructor?.name }),
@@ -124,7 +124,7 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
124
124
  catch (e) {
125
125
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`);
126
126
  const errorResponse = getErrorResponse(e);
127
- channel.send({
127
+ await channel.send({
128
128
  error: errorResponse?.message ??
129
129
  (isProduction() ? 'Internal server error' : e.message),
130
130
  ...(!isProduction() && { errorName: e.constructor?.name }),
@@ -149,12 +149,14 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
149
149
  catch (e) {
150
150
  singletonServices.logger.error(e);
151
151
  const errorResponse = getErrorResponse(e);
152
- channel.send({
152
+ await channel.send({
153
153
  error: errorResponse?.message ??
154
154
  (isProduction() ? 'Internal server error' : e.message),
155
155
  ...(!isProduction() && { errorName: e.constructor?.name }),
156
156
  });
157
- setTimeout(() => channel.close(), 200);
157
+ setTimeout(() => {
158
+ Promise.resolve(channel.close()).catch((closeError) => singletonServices.logger.error(closeError));
159
+ }, 200);
158
160
  }
159
161
  });
160
162
  if (onBinaryMessage) {
@@ -6,6 +6,7 @@ export declare class LocalEventHubService<Data extends Record<string, any> = {}>
6
6
  subscribe<T extends keyof Data>(topic: T, channelId: string): void | Promise<void>;
7
7
  unsubscribe<T extends keyof Data>(topic: T, channelId: string): void | Promise<void>;
8
8
  publish<T extends keyof Data>(topic: T, channelId: string | null, data: Data[T], isBinary?: boolean): void | Promise<void>;
9
+ private deliver;
9
10
  onChannelOpened(channelHandler: PikkuChannelHandler): void;
10
11
  onChannelClosed(channelId: string): void;
11
12
  }
@@ -17,6 +17,9 @@ export class LocalEventHubService {
17
17
  }
18
18
  }
19
19
  publish(topic, channelId, data, isBinary) {
20
+ return this.deliver(topic, channelId, data, isBinary);
21
+ }
22
+ async deliver(topic, channelId, data, isBinary) {
20
23
  const subscribedChannelIds = this.subscriptions.get(topic);
21
24
  if (!subscribedChannelIds) {
22
25
  return;
@@ -27,7 +30,7 @@ export class LocalEventHubService {
27
30
  const channel = this.channels.get(toChannelId);
28
31
  if (channel) {
29
32
  try {
30
- channel.send(data, isBinary);
33
+ await channel.send(data, isBinary);
31
34
  }
32
35
  catch {
33
36
  this.onChannelClosed(toChannelId);
@@ -42,7 +42,7 @@ export async function executeCLIViaChannel({ programName, pikkuWS, args = proces
42
42
  const renderer = renderers[commandId] || defaultRenderer || defaultJSONRenderer;
43
43
  return new Promise((resolve, reject) => {
44
44
  const commandRoute = pikkuWS.getRoute('command');
45
- const responseHandler = (response) => {
45
+ const responseHandler = async (response) => {
46
46
  if (response?.action === 'cli-control' &&
47
47
  response?.event === 'complete') {
48
48
  commandRoute.unsubscribe(commandId, responseHandler);
@@ -52,7 +52,12 @@ export async function executeCLIViaChannel({ programName, pikkuWS, args = proces
52
52
  }
53
53
  // A CLI channel response is rendered off-process: there are no services
54
54
  // to hand the renderer, and every CLI renderer ignores the argument.
55
- renderer(null, response, undefined);
55
+ try {
56
+ await renderer(null, response, undefined);
57
+ }
58
+ catch (error) {
59
+ reject(error);
60
+ }
56
61
  };
57
62
  commandRoute.subscribe(commandId, responseHandler);
58
63
  pikkuWS.ws.addEventListener('close', () => {
@@ -35,11 +35,11 @@ export async function executeRawCLIViaChannel({ pikkuWS, args = process.argv.sli
35
35
  // event loop open.
36
36
  const runEnded = new AbortController();
37
37
  const approver = approve ?? approverForMode(mode, { signal: runEnded.signal });
38
- const render = (commandId, data) => {
38
+ const render = async (commandId, data) => {
39
39
  const renderer = (commandId ? renderers[commandId] : undefined) ||
40
40
  defaultRenderer ||
41
41
  defaultJSONRenderer;
42
- renderer(clientServices, data, undefined);
42
+ await renderer(clientServices, data, undefined);
43
43
  };
44
44
  return new Promise((resolve) => {
45
45
  const commandRoute = pikkuWS.getRoute('command');
@@ -88,10 +88,14 @@ export async function executeRawCLIViaChannel({ pikkuWS, args = process.argv.sli
88
88
  // echo, and rendering that would print noise between real output.
89
89
  switch (message?.action) {
90
90
  case 'cli-output':
91
- render(message.commandId, message.data);
91
+ render(message.commandId, message.data).catch((e) => {
92
+ console.error(e);
93
+ });
92
94
  break;
93
95
  case 'cli-result':
94
- render(message.commandId, message.result);
96
+ render(message.commandId, message.result).catch((e) => {
97
+ console.error(e);
98
+ });
95
99
  break;
96
100
  case 'cli-help':
97
101
  console.log(message.help);
@@ -255,7 +255,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
255
255
  return result;
256
256
  }
257
257
  finally {
258
- channel.close();
258
+ await channel.close();
259
259
  }
260
260
  }
261
261
  export const pikkuCLIRender = (renderer) => {
@@ -232,7 +232,7 @@ const wireWebsocketGateway = (config) => {
232
232
  senderId: '',
233
233
  platform: adapter.name,
234
234
  send: async (msg) => {
235
- wire.channel?.send(msg);
235
+ await wire.channel?.send(msg);
236
236
  },
237
237
  };
238
238
  },
@@ -249,7 +249,7 @@ const wireWebsocketGateway = (config) => {
249
249
  senderId: parsed.senderId,
250
250
  platform: adapter.name,
251
251
  send: async (msg) => {
252
- wire.channel?.send(msg);
252
+ await wire.channel?.send(msg);
253
253
  },
254
254
  };
255
255
  wire.gateway = gateway;
@@ -268,7 +268,7 @@ const wireWebsocketGateway = (config) => {
268
268
  ? await runMiddleware(services, wire, gatewayMiddleware, invoke)
269
269
  : await invoke();
270
270
  if (result && (result.text || result.richContent || result.attachments)) {
271
- wire.channel?.send(result);
271
+ await wire.channel?.send(result);
272
272
  }
273
273
  },
274
274
  });
@@ -149,19 +149,19 @@ const executeRoute = async (services, matchedRoute, http, options) => {
149
149
  const channelRef = channel;
150
150
  const channelHandler = {
151
151
  getChannel: () => channelRef,
152
- send: (data, isBinary) => {
152
+ send: async (data, isBinary) => {
153
153
  if (isBinary)
154
- channelRef.sendBinary(data);
154
+ await channelRef.sendBinary(data);
155
155
  else
156
- channelRef.send(data);
156
+ await channelRef.send(data);
157
157
  },
158
158
  sendBinary: (data) => channelRef.sendBinary(data),
159
159
  };
160
160
  singletonServices.eventHub.onChannelOpened(channelHandler);
161
161
  const originalClose = channel.close;
162
- channel.close = () => {
162
+ channel.close = async () => {
163
163
  singletonServices.eventHub.onChannelClosed(channelId);
164
- originalClose();
164
+ await originalClose();
165
165
  };
166
166
  }
167
167
  }
@@ -201,7 +201,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
201
201
  }
202
202
  }
203
203
  catch { }
204
- channel?.close();
204
+ await channel?.close();
205
205
  return { result };
206
206
  }
207
207
  throw e;
@@ -14,6 +14,9 @@ export declare class RemoteAddonRequestError extends PikkuError {
14
14
  readonly httpStatus: number;
15
15
  constructor(namespace: string, fnName: string, status: number, detail: string);
16
16
  }
17
+ export declare class AddonNotUsedError extends PikkuError {
18
+ constructor(caller: string, target: string);
19
+ }
17
20
  export declare const resolveNamespace: (namespacedFunction: string) => ResolvedFunction | null;
18
21
  export declare class ContextAwareRPCService {
19
22
  private services;
@@ -26,6 +29,7 @@ export declare class ContextAwareRPCService {
26
29
  }, packageName?: string | null);
27
30
  rpcExposed(funcName: string, data: any): Promise<any>;
28
31
  rpc<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
32
+ private mapUsedAddon;
29
33
  /**
30
34
  * Resolution half of an addon RPC call: never executes anything, so an
31
35
  * `RPCNotFoundError` thrown by the function itself can never be mistaken for
@@ -1,3 +1,4 @@
1
+ import { addonCallerOf, addonMayReach } from '../addon/addon-reach.js';
1
2
  import { forwardStepName, isCompensationStepName, } from '../../function/compensation-name.js';
2
3
  import { runPikkuFunc } from '../../function/function-runner.js';
3
4
  import { addonInstanceForNamespace } from '../addon/addon-runner.js';
@@ -43,6 +44,15 @@ addError(RemoteAddonRequestError, {
43
44
  status: 502,
44
45
  message: 'Remote addon request failed.',
45
46
  });
47
+ export class AddonNotUsedError extends PikkuError {
48
+ constructor(caller, target) {
49
+ super(`Addon '${caller}' called '${target}' but does not list it in its wireAddon 'uses'.`);
50
+ }
51
+ }
52
+ addError(AddonNotUsedError, {
53
+ status: 403,
54
+ message: 'Addon called another addon it does not use.',
55
+ });
46
56
  export const resolveNamespace = (namespacedFunction) => {
47
57
  const colonIndex = namespacedFunction.indexOf(':');
48
58
  if (colonIndex === -1) {
@@ -68,6 +78,7 @@ const resolvePikkuFunction = (rpcName, packageName = null) => {
68
78
  if (pkgMeta) {
69
79
  return { pikkuFuncId: pkgMeta.pikkuFuncId || rpcName, packageName };
70
80
  }
81
+ throw new RPCNotFoundError(rpcName);
71
82
  }
72
83
  const rpc = pikkuState(null, 'rpc', 'meta');
73
84
  let rpcMeta = rpc[rpcName];
@@ -119,6 +130,10 @@ export class ContextAwareRPCService {
119
130
  this.packageName = packageName;
120
131
  }
121
132
  async rpcExposed(funcName, data) {
133
+ const addonCaller = addonCallerOf(this.wire);
134
+ if (addonCaller && !addonMayReach(addonCaller, funcName)) {
135
+ throw new AddonNotUsedError(addonCaller, funcName);
136
+ }
122
137
  let functionMeta;
123
138
  let resolvedAddon = null;
124
139
  if (funcName.includes(':')) {
@@ -159,6 +174,7 @@ export class ContextAwareRPCService {
159
174
  ...this.wire,
160
175
  };
161
176
  if (funcName.includes(':')) {
177
+ funcName = this.mapUsedAddon(funcName);
162
178
  const addonCall = this.resolveAddonFunction(funcName);
163
179
  if (addonCall !== NOT_RESOLVED) {
164
180
  return await this.executeAddonFunction(addonCall, data, updatedWire);
@@ -170,7 +186,7 @@ export class ContextAwareRPCService {
170
186
  }
171
187
  catch (e) {
172
188
  if (e instanceof RPCNotFoundError) {
173
- if (this.services.deploymentService) {
189
+ if (!this.packageName && this.services.deploymentService) {
174
190
  const session = await resolveWireSession(this.wire);
175
191
  return this.services.deploymentService.invoke(funcName, data, session, this.wire.traceId);
176
192
  }
@@ -189,6 +205,20 @@ export class ContextAwareRPCService {
189
205
  addonInstance,
190
206
  });
191
207
  }
208
+ mapUsedAddon(funcName) {
209
+ const caller = this.wire.addonNamespace;
210
+ if (!caller)
211
+ return funcName;
212
+ const colon = funcName.indexOf(':');
213
+ const prefix = funcName.slice(0, colon);
214
+ if (prefix === caller)
215
+ return funcName;
216
+ const uses = pikkuState(null, 'addons', 'packages').get(caller)?.uses;
217
+ const mapped = uses?.[prefix];
218
+ if (mapped)
219
+ return `${mapped}${funcName.slice(colon)}`;
220
+ throw new AddonNotUsedError(caller, funcName);
221
+ }
192
222
  /**
193
223
  * Resolution half of an addon RPC call: never executes anything, so an
194
224
  * `RPCNotFoundError` thrown by the function itself can never be mistaken for
@@ -243,6 +273,9 @@ export class ContextAwareRPCService {
243
273
  credentialGrants: resolved.addonConfig?.credentialGrants,
244
274
  globalSecrets: resolved.addonConfig?.globalSecrets,
245
275
  globalCredentials: resolved.addonConfig?.globalCredentials,
276
+ contentBucket: resolved.addonConfig?.contentBucket,
277
+ contentGrants: resolved.addonConfig?.contentGrants,
278
+ globalContent: resolved.addonConfig?.globalContent,
246
279
  },
247
280
  };
248
281
  }
@@ -305,6 +338,7 @@ export class ContextAwareRPCService {
305
338
  ...wire,
306
339
  };
307
340
  if (rpcName.includes(':')) {
341
+ rpcName = this.mapUsedAddon(rpcName);
308
342
  const addonCall = this.resolveAddonFunction(rpcName);
309
343
  if (addonCall !== NOT_RESOLVED) {
310
344
  return await this.executeAddonFunction(addonCall, data, mergedWire);
@@ -315,7 +349,9 @@ export class ContextAwareRPCService {
315
349
  resolved = resolvePikkuFunction(rpcName, this.packageName);
316
350
  }
317
351
  catch (e) {
318
- if (e instanceof RPCNotFoundError && this.services.deploymentService) {
352
+ if (e instanceof RPCNotFoundError &&
353
+ !this.packageName &&
354
+ this.services.deploymentService) {
319
355
  const session = await resolveWireSession(mergedWire);
320
356
  return this.services.deploymentService.invoke(rpcName, data, session, mergedWire.traceId);
321
357
  }
@@ -337,6 +373,9 @@ export class ContextAwareRPCService {
337
373
  if (!this.services.workflowService) {
338
374
  throw new Error('WorkflowService service not available');
339
375
  }
376
+ if (workflowName.includes(':')) {
377
+ workflowName = this.mapUsedAddon(workflowName);
378
+ }
340
379
  const parentRunId = this.wire.workflowStep?.runId ?? this.wire.graph?.runId;
341
380
  const wire = options?.wire ?? {
342
381
  type: this.wire.wireType ?? 'unknown',
@@ -59,6 +59,12 @@ export interface ResolvedFunction {
59
59
  globalSecrets?: string;
60
60
  /** Set by the consuming app: hand this instance the unscoped `CredentialService` */
61
61
  globalCredentials?: string;
62
+ /** Set by the consuming app: the content folder this instance owns */
63
+ contentBucket?: string;
64
+ /** Set by the consuming app: paths outside its folder this instance may read or write */
65
+ contentGrants?: Record<string, 'read' | 'write'>;
66
+ /** Set by the consuming app: hand this instance the unscoped `ContentService` */
67
+ globalContent?: string;
62
68
  /** Set by `wireRemoteAddon`: dispatch this namespace's RPCs over HTTP */
63
69
  remote?: boolean;
64
70
  serverUrl?: string | ((services: any) => string | Promise<string>);
@@ -43,7 +43,7 @@ export async function runScheduledTask({ name, session, traceId, }) {
43
43
  const meta = pikkuState(null, 'scheduler', 'meta')[name];
44
44
  const userSession = new PikkuSessionService(singletonServices.sessionStore);
45
45
  if (session) {
46
- userSession.set(session);
46
+ await userSession.set(session);
47
47
  }
48
48
  if (!task) {
49
49
  throw new ScheduledTaskNotFoundError(`Scheduled task not found: ${name}`);
@@ -30,3 +30,22 @@ import type { CoreScopes } from './scope.types.js';
30
30
  * ```
31
31
  */
32
32
  export declare const defineScope: (_config: CoreScopes) => void;
33
+ /**
34
+ * Declares scopes by id without describing them, so an app can grant a scope
35
+ * (`defineSystemRole`, a function's `scopes`) whose full tree belongs to an
36
+ * addon it has not wired. `wireAddon({ scopes })` does not declare anything: it
37
+ * only requires the scope of the addon's functions, and the tree itself arrives
38
+ * with the addon's metadata. Declaring by id lets an app grant
39
+ * `pikku:console` without wiring `@pikku/addon-console`, which would otherwise
40
+ * be the only way to make that grant valid.
41
+ *
42
+ * Each id declares that node and its ancestors. When the addon IS wired, its
43
+ * own description wins and this call adds nothing it does not already have; the
44
+ * two never conflict, unlike a second `defineScope` for the same root.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * declareScopes(['pikku:console'])
49
+ * ```
50
+ */
51
+ export declare const declareScopes: (_ids: string[]) => void;