@zooid/core 0.12.0 → 0.14.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/dist/index.d.ts +222 -5
- package/dist/index.js +262 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/acp-registry.test.ts +14 -0
- package/src/acp-registry.ts +14 -17
- package/src/acp-types.ts +2 -1
- package/src/config.test.ts +1 -0
- package/src/config.ts +314 -0
- package/src/index.ts +12 -11
- package/src/match-expression.test.ts +61 -0
- package/src/match-expression.ts +33 -0
- package/src/render-template.test.ts +38 -0
- package/src/render-template.ts +32 -0
- package/src/task-actions.ts +84 -0
- package/src/transport-context.test.ts +11 -4
- package/src/transport-context.ts +18 -2
- package/src/triggers-config.test.ts +168 -0
- package/src/triggers-messages-config.test.ts +146 -0
- package/src/triggers-webhook-config.test.ts +143 -0
- package/src/types.ts +65 -0
package/src/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { parse } from 'yaml'
|
|
|
4
4
|
import type { AcpAgentSpec } from './acp-types.js'
|
|
5
5
|
import { isPreset } from '@zooid/acp-client'
|
|
6
6
|
import { interpolateEnv, interpolateString } from './env-interpolation.js'
|
|
7
|
+
import { compileMatch } from './match-expression.js'
|
|
7
8
|
import type {
|
|
8
9
|
AgentConfig,
|
|
9
10
|
CliFlags,
|
|
@@ -15,10 +16,15 @@ import type {
|
|
|
15
16
|
MountConfig,
|
|
16
17
|
RoomBinding,
|
|
17
18
|
TransportConfig,
|
|
19
|
+
TriggerConfig,
|
|
20
|
+
TriggerMessage,
|
|
21
|
+
WebhookTriggerConfig,
|
|
18
22
|
ZooidConfig,
|
|
19
23
|
ZooidContainerConfig,
|
|
20
24
|
} from './types.js'
|
|
21
25
|
|
|
26
|
+
const WEBHOOK_PROVIDERS = ['github', 'stripe', 'slack', 'standard', 'custom'] as const
|
|
27
|
+
|
|
22
28
|
const SLUG_RE = /^[a-z0-9-]+$/
|
|
23
29
|
|
|
24
30
|
export interface LoadZooidConfigOptions {
|
|
@@ -29,6 +35,13 @@ export interface LoadZooidConfigOptions {
|
|
|
29
35
|
* path.
|
|
30
36
|
*/
|
|
31
37
|
configDir?: string
|
|
38
|
+
/**
|
|
39
|
+
* Cron-expression validator, called as `validateCron(name, expr)` and
|
|
40
|
+
* expected to throw on an invalid expression. `core` takes no cron
|
|
41
|
+
* dependency — `cli` passes croner's parser at load time. Defaults to a
|
|
42
|
+
* field-count check sufficient to catch malformed input at parse time.
|
|
43
|
+
*/
|
|
44
|
+
validateCron?: (name: string, expr: string) => void
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
@@ -790,6 +803,297 @@ function parseAgents(
|
|
|
790
803
|
return result
|
|
791
804
|
}
|
|
792
805
|
|
|
806
|
+
function defaultValidateCron(name: string, expr: string): void {
|
|
807
|
+
const parts = expr.trim().split(/\s+/)
|
|
808
|
+
if (parts.length < 5 || parts.length > 7) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`triggers.${name}.schedule: invalid cron expression ${JSON.stringify(expr)}`,
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Expand a trigger's `as:` to a full MXID. Mirrors the explicit-`user_id`
|
|
817
|
+
* path in `parseTransportBinding`: a value already containing `:` is used
|
|
818
|
+
* as given (and validated); a bare localpart (`cron` or `@cron`) gets
|
|
819
|
+
* `:<server>` appended from the workforce's sole matrix transport. Unlike
|
|
820
|
+
* the *default* user_id (omitted case), this never auto-prefixes a
|
|
821
|
+
* workstation — an explicit value is used as written, so a
|
|
822
|
+
* workstation-scoped bot is written explicitly (`as: myworkstation.cron`).
|
|
823
|
+
*/
|
|
824
|
+
function expandTriggerAs(
|
|
825
|
+
name: string,
|
|
826
|
+
as: string,
|
|
827
|
+
transports: Record<string, TransportConfig>,
|
|
828
|
+
): string {
|
|
829
|
+
if (as.includes(':')) {
|
|
830
|
+
if (!MATRIX_USER_ID_RE.test(as)) {
|
|
831
|
+
throw new Error(`triggers.${name}.as: must be a full MXID (got ${JSON.stringify(as)})`)
|
|
832
|
+
}
|
|
833
|
+
return as
|
|
834
|
+
}
|
|
835
|
+
const bare = as.startsWith('@') ? as : `@${as}`
|
|
836
|
+
if (!MATRIX_USER_LOCALPART_RE.test(bare)) {
|
|
837
|
+
throw new Error(`triggers.${name}.as: must be a full MXID (got ${JSON.stringify(as)})`)
|
|
838
|
+
}
|
|
839
|
+
const matrixTransports = Object.values(transports).filter(
|
|
840
|
+
(t): t is MatrixTransportConfig => t.type === 'matrix',
|
|
841
|
+
)
|
|
842
|
+
if (matrixTransports.length !== 1) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`triggers.${name}.as: "${as}" is a bare localpart, which requires exactly one matrix ` +
|
|
845
|
+
`transport to expand against (found ${matrixTransports.length}). Use a full MXID instead.`,
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
const serverName = deriveServerName(matrixTransports[0]!.user_namespace)
|
|
849
|
+
return `${bare}:${serverName}`
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function parseTriggers(
|
|
853
|
+
raw: unknown,
|
|
854
|
+
agents: Record<string, AgentConfig>,
|
|
855
|
+
transports: Record<string, TransportConfig>,
|
|
856
|
+
validateCron: (name: string, expr: string) => void,
|
|
857
|
+
processEnv: NodeJS.ProcessEnv,
|
|
858
|
+
configDir: string | undefined,
|
|
859
|
+
): Record<string, TriggerConfig> {
|
|
860
|
+
if (raw === undefined || raw === null) return {}
|
|
861
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
862
|
+
throw new Error('triggers: must be a mapping')
|
|
863
|
+
}
|
|
864
|
+
const result: Record<string, TriggerConfig> = {}
|
|
865
|
+
for (const [name, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
866
|
+
if (!val || typeof val !== 'object' || Array.isArray(val)) {
|
|
867
|
+
throw new Error(`triggers.${name} must be a mapping`)
|
|
868
|
+
}
|
|
869
|
+
const t = val as Record<string, unknown>
|
|
870
|
+
|
|
871
|
+
if (t.run !== undefined) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`triggers.${name}.run: is not supported — a trigger posts a message and the ` +
|
|
874
|
+
`agent runs what needs running. See [ZOD081] §Concept.`,
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (t.schedule === undefined && t.webhook === undefined) {
|
|
879
|
+
throw new Error(`triggers.${name}: must specify schedule: or webhook:`)
|
|
880
|
+
}
|
|
881
|
+
if (t.schedule !== undefined && t.webhook !== undefined) {
|
|
882
|
+
throw new Error(`triggers.${name}: specify either schedule: or webhook:, not both`)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let schedule: string | undefined
|
|
886
|
+
if (t.schedule !== undefined) {
|
|
887
|
+
if (typeof t.schedule !== 'string' || t.schedule.length === 0) {
|
|
888
|
+
throw new Error(`triggers.${name}.schedule: must be a non-empty string`)
|
|
889
|
+
}
|
|
890
|
+
validateCron(name, t.schedule)
|
|
891
|
+
schedule = t.schedule
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let webhook: WebhookTriggerConfig | undefined
|
|
895
|
+
if (t.webhook !== undefined) {
|
|
896
|
+
webhook = parseWebhookTrigger(name, t.webhook, processEnv, configDir)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (typeof t.as !== 'string' || t.as.length === 0) {
|
|
900
|
+
throw new Error(`triggers.${name}.as: must be a non-empty string`)
|
|
901
|
+
}
|
|
902
|
+
const as = expandTriggerAs(name, t.as, transports)
|
|
903
|
+
|
|
904
|
+
const hasFlat = t.room !== undefined || t.mention !== undefined || t.text !== undefined ||
|
|
905
|
+
t.match !== undefined
|
|
906
|
+
const hasMessages = t.messages !== undefined
|
|
907
|
+
if (hasFlat && hasMessages) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
`triggers.${name}: specify either room:/mention:/text: or messages:, not both`,
|
|
910
|
+
)
|
|
911
|
+
}
|
|
912
|
+
if (!hasFlat && !hasMessages) {
|
|
913
|
+
throw new Error(`triggers.${name}: must specify room:/mention:/text: or messages:`)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let rawMessages: unknown[]
|
|
917
|
+
if (hasFlat) {
|
|
918
|
+
// Only a flat trigger's own top-level `match:` gets this unindexed
|
|
919
|
+
// name — once desugared, per-entry validation always speaks in terms
|
|
920
|
+
// of `messages[i]`, whether the entry came from `flat` or `messages:`.
|
|
921
|
+
if (t.match !== undefined && !webhook) {
|
|
922
|
+
throw new Error(`triggers.${name}.match: only applies to a webhook: trigger`)
|
|
923
|
+
}
|
|
924
|
+
rawMessages = [{ room: t.room, mention: t.mention, text: t.text, match: t.match }]
|
|
925
|
+
} else {
|
|
926
|
+
if (!Array.isArray(t.messages)) {
|
|
927
|
+
throw new Error(`triggers.${name}.messages: must be a list`)
|
|
928
|
+
}
|
|
929
|
+
if (t.messages.length === 0) {
|
|
930
|
+
throw new Error(`triggers.${name}.messages: must not be empty`)
|
|
931
|
+
}
|
|
932
|
+
rawMessages = t.messages
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const messages = rawMessages.map((m, i) =>
|
|
936
|
+
parseTriggerMessage(name, i, m, agents, as, !!webhook),
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
const entry: TriggerConfig = { as, messages }
|
|
940
|
+
if (schedule !== undefined) entry.schedule = schedule
|
|
941
|
+
if (webhook !== undefined) entry.webhook = webhook
|
|
942
|
+
result[name] = entry
|
|
943
|
+
}
|
|
944
|
+
return result
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Parse and validate one entry of `messages:` (or the single entry a flat
|
|
949
|
+
* trigger desugars into). Indexed error names (`triggers.<name>.messages[i]`)
|
|
950
|
+
* apply uniformly here regardless of which spelling produced the entry —
|
|
951
|
+
* only the flat-level `match:` presence check (in `parseTriggers`) still
|
|
952
|
+
* speaks in the unindexed, trigger-level name.
|
|
953
|
+
*/
|
|
954
|
+
function parseTriggerMessage(
|
|
955
|
+
name: string,
|
|
956
|
+
index: number,
|
|
957
|
+
raw: unknown,
|
|
958
|
+
agents: Record<string, AgentConfig>,
|
|
959
|
+
as: string,
|
|
960
|
+
hasWebhook: boolean,
|
|
961
|
+
): TriggerMessage {
|
|
962
|
+
const label = `triggers.${name}.messages[${index}]`
|
|
963
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
964
|
+
throw new Error(`${label}: must be a mapping`)
|
|
965
|
+
}
|
|
966
|
+
const m = raw as Record<string, unknown>
|
|
967
|
+
|
|
968
|
+
if (typeof m.room !== 'string' || m.room.length === 0) {
|
|
969
|
+
throw new Error(`${label}.room: must be a non-empty string`)
|
|
970
|
+
}
|
|
971
|
+
if (!MATRIX_ROOM_IDENT_RE.test(m.room)) {
|
|
972
|
+
throw new Error(`${label}.room: must start with '#' or '!' (got ${JSON.stringify(m.room)})`)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (typeof m.mention !== 'string' || m.mention.length === 0) {
|
|
976
|
+
throw new Error(`${label}.mention: must be a non-empty string`)
|
|
977
|
+
}
|
|
978
|
+
const mentionedAgent = agents[m.mention]
|
|
979
|
+
if (!mentionedAgent) {
|
|
980
|
+
throw new Error(`${label}.mention: unknown agent "${m.mention}"`)
|
|
981
|
+
}
|
|
982
|
+
if (!mentionedAgent.matrix) {
|
|
983
|
+
throw new Error(
|
|
984
|
+
`${label}.mention: agent "${m.mention}" has no matrix: binding — a trigger posts ` +
|
|
985
|
+
`through Matrix, so the mentioned agent must be matrix-bound.`,
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
// router.ts never routes an event back to its own sender
|
|
989
|
+
// (`event.sender === a.userId` short-circuits the match), so a trigger
|
|
990
|
+
// that posts as the very agent it mentions would silently never wake it.
|
|
991
|
+
if (as === mentionedAgent.matrix.user_id) {
|
|
992
|
+
throw new Error(
|
|
993
|
+
`triggers.${name}.as: must not equal the mentioned agent's own MXID (${as}) — a message ` +
|
|
994
|
+
`an agent sends never routes back to itself, so this trigger would silently never wake ` +
|
|
995
|
+
`"${m.mention}". Post as a different identity (a dedicated bot, or another agent's).`,
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (typeof m.text !== 'string' || m.text.length === 0) {
|
|
1000
|
+
throw new Error(`${label}.text: must be a non-empty string`)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const message: TriggerMessage = { room: m.room, mention: m.mention, text: m.text }
|
|
1004
|
+
|
|
1005
|
+
if (m.match !== undefined) {
|
|
1006
|
+
if (typeof m.match !== 'string' || m.match.length === 0) {
|
|
1007
|
+
throw new Error(`${label}.match: must be a non-empty string`)
|
|
1008
|
+
}
|
|
1009
|
+
if (!hasWebhook) {
|
|
1010
|
+
throw new Error(`${label}.match: only applies to a webhook: trigger`)
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
message.match = compileMatch(m.match)
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
throw new Error(`${label}.match: ${(err as Error).message}`)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return message
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Parse and validate a `webhook:` block. The secret is interpolated through
|
|
1024
|
+
* `interpolateString` (not `interpolateEnv`'s deny-listed map form) — it is a
|
|
1025
|
+
* single scalar value, and `ZOOID_*` references are legitimate here just as
|
|
1026
|
+
* they are for a transport's `as_token` — [[ZOD082]] §Design 4, Secrets.
|
|
1027
|
+
*/
|
|
1028
|
+
function parseWebhookTrigger(
|
|
1029
|
+
name: string,
|
|
1030
|
+
raw: unknown,
|
|
1031
|
+
processEnv: NodeJS.ProcessEnv,
|
|
1032
|
+
configDir: string | undefined,
|
|
1033
|
+
): WebhookTriggerConfig {
|
|
1034
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1035
|
+
throw new Error(`triggers.${name}.webhook: must be a mapping`)
|
|
1036
|
+
}
|
|
1037
|
+
const w = raw as Record<string, unknown>
|
|
1038
|
+
|
|
1039
|
+
if (typeof w.provider !== 'string' || w.provider.length === 0) {
|
|
1040
|
+
throw new Error(`triggers.${name}.webhook.provider: must be a non-empty string`)
|
|
1041
|
+
}
|
|
1042
|
+
if (!(WEBHOOK_PROVIDERS as readonly string[]).includes(w.provider)) {
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`triggers.${name}.webhook.provider: unknown provider ${JSON.stringify(w.provider)} ` +
|
|
1045
|
+
`(expected one of: ${WEBHOOK_PROVIDERS.join(', ')})`,
|
|
1046
|
+
)
|
|
1047
|
+
}
|
|
1048
|
+
const provider = w.provider as WebhookTriggerConfig['provider']
|
|
1049
|
+
|
|
1050
|
+
if (w.event !== undefined) {
|
|
1051
|
+
throw new Error(`triggers.${name}.webhook.event: no longer supported — use match:`)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (typeof w.secret !== 'string' || w.secret.length === 0) {
|
|
1055
|
+
throw new Error(`triggers.${name}.webhook.secret: is required`)
|
|
1056
|
+
}
|
|
1057
|
+
const secret = interpolateString(w.secret, processEnv)
|
|
1058
|
+
|
|
1059
|
+
const config: WebhookTriggerConfig = { provider, secret }
|
|
1060
|
+
|
|
1061
|
+
if (provider !== 'custom') {
|
|
1062
|
+
// Silently ignoring this would leave an operator believing they had
|
|
1063
|
+
// configured a verifier that never ran.
|
|
1064
|
+
if (w.verify !== undefined) {
|
|
1065
|
+
throw new Error(
|
|
1066
|
+
`triggers.${name}.webhook.verify: only applies to provider: custom ` +
|
|
1067
|
+
`(this trigger uses provider: ${provider}, whose signing scheme is built in)`,
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
return config
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// provider: custom — verification is a function the operator supplies,
|
|
1074
|
+
// since no set of declarative fields covers every scheme (ed25519, SHA-1
|
|
1075
|
+
// over sorted params, bespoke timestamped base strings). Resolved here;
|
|
1076
|
+
// the daemon imports it at startup so a bad path fails fast.
|
|
1077
|
+
if (typeof w.verify !== 'string' || w.verify.length === 0) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`triggers.${name}.webhook.verify: is required when provider: custom ` +
|
|
1080
|
+
`(path to a module exporting the verifier function)`,
|
|
1081
|
+
)
|
|
1082
|
+
}
|
|
1083
|
+
if (isAbsolute(w.verify)) {
|
|
1084
|
+
config.verify = w.verify
|
|
1085
|
+
} else if (!configDir) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`triggers.${name}.webhook.verify: relative path ${JSON.stringify(w.verify)} requires ` +
|
|
1088
|
+
`configDir (zooid.yaml directory) — pass it via loadZooidConfig(yaml, { configDir })`,
|
|
1089
|
+
)
|
|
1090
|
+
} else {
|
|
1091
|
+
config.verify = pathResolve(configDir, w.verify)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return config
|
|
1095
|
+
}
|
|
1096
|
+
|
|
793
1097
|
function parseRuntime(raw: unknown): 'local' | 'docker' | 'podman' {
|
|
794
1098
|
const runtime = raw ?? 'docker'
|
|
795
1099
|
if (runtime !== 'local' && runtime !== 'docker' && runtime !== 'podman') {
|
|
@@ -860,12 +1164,21 @@ export function loadZooidConfig(
|
|
|
860
1164
|
const transports = parseTransports(r.transports, processEnv, workstation)
|
|
861
1165
|
const hooks = zooidHooks(r)
|
|
862
1166
|
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv, opts.configDir)
|
|
1167
|
+
const triggers = parseTriggers(
|
|
1168
|
+
r.triggers,
|
|
1169
|
+
agents,
|
|
1170
|
+
transports,
|
|
1171
|
+
opts.validateCron ?? defaultValidateCron,
|
|
1172
|
+
processEnv,
|
|
1173
|
+
opts.configDir,
|
|
1174
|
+
)
|
|
863
1175
|
|
|
864
1176
|
const cfg: ZooidConfig = {
|
|
865
1177
|
runtime,
|
|
866
1178
|
transports,
|
|
867
1179
|
agents,
|
|
868
1180
|
hooks,
|
|
1181
|
+
triggers,
|
|
869
1182
|
}
|
|
870
1183
|
if (workstation !== undefined) cfg.workstation = workstation
|
|
871
1184
|
if (r.container !== undefined && r.container !== null) {
|
|
@@ -955,6 +1268,7 @@ export function mergeCliFlags(base: ZooidConfig, flags: CliFlags): ZooidConfig {
|
|
|
955
1268
|
transports: base.transports,
|
|
956
1269
|
agents: base.agents,
|
|
957
1270
|
hooks: { ...base.hooks },
|
|
1271
|
+
triggers: base.triggers,
|
|
958
1272
|
}
|
|
959
1273
|
if (runtime === 'docker' || runtime === 'podman') {
|
|
960
1274
|
const image = flags.image ?? base.container?.image
|
package/src/index.ts
CHANGED
|
@@ -7,10 +7,10 @@ export {
|
|
|
7
7
|
findConfigFile,
|
|
8
8
|
} from './config.js'
|
|
9
9
|
export type { LoadZooidConfigOptions } from './config.js'
|
|
10
|
-
export {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from './acp-registry.js'
|
|
10
|
+
export { renderTemplate } from './render-template.js'
|
|
11
|
+
export { compileMatch, evaluateMatch } from './match-expression.js'
|
|
12
|
+
export type { MatchContext } from './match-expression.js'
|
|
13
|
+
export { AcpAgentRegistry, resolveAcpAgentSpec } from './acp-registry.js'
|
|
14
14
|
export {
|
|
15
15
|
ApprovalCorrelator,
|
|
16
16
|
type RegisteredApproval,
|
|
@@ -24,12 +24,7 @@ export type {
|
|
|
24
24
|
ContextSpawnFactory,
|
|
25
25
|
} from './acp-registry.js'
|
|
26
26
|
export type { TapEvent } from '@zooid/acp-client'
|
|
27
|
-
export type {
|
|
28
|
-
AcpAgentSpec,
|
|
29
|
-
AcpMount,
|
|
30
|
-
AcpRuntime,
|
|
31
|
-
AcpSpawnSpec,
|
|
32
|
-
} from './acp-types.js'
|
|
27
|
+
export type { AcpAgentSpec, AcpMount, AcpRuntime, AcpSpawnSpec } from './acp-types.js'
|
|
33
28
|
export type {
|
|
34
29
|
AgentConfig,
|
|
35
30
|
ContainerConfig,
|
|
@@ -46,14 +41,20 @@ export type {
|
|
|
46
41
|
Transport,
|
|
47
42
|
InboundMessage,
|
|
48
43
|
ThreadRef,
|
|
44
|
+
TriggerConfig,
|
|
45
|
+
TriggerMessage,
|
|
46
|
+
WebhookTriggerConfig,
|
|
49
47
|
} from './types.js'
|
|
50
48
|
export type {
|
|
51
49
|
HistoryOptions,
|
|
52
50
|
HistoryPage,
|
|
53
51
|
Message,
|
|
54
52
|
Member,
|
|
55
|
-
|
|
53
|
+
RoomInfo,
|
|
54
|
+
SendMessageInput,
|
|
55
|
+
SendMessageResult,
|
|
56
56
|
ThreadOverview,
|
|
57
57
|
ThreadOverviewPage,
|
|
58
58
|
TransportContextProvider,
|
|
59
59
|
} from './transport-context.js'
|
|
60
|
+
export * from './task-actions.js'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { compileMatch, evaluateMatch } from './match-expression.js'
|
|
3
|
+
|
|
4
|
+
const body = {
|
|
5
|
+
action: 'closed',
|
|
6
|
+
number: 42,
|
|
7
|
+
pull_request: { merged: true },
|
|
8
|
+
repository: { full_name: 'zooid-ai/zooid' },
|
|
9
|
+
}
|
|
10
|
+
const ctx = { event: 'pull_request', body, headers: { 'x-github-event': 'pull_request' }, output: '{}' }
|
|
11
|
+
|
|
12
|
+
describe('evaluateMatch', () => {
|
|
13
|
+
it('is true when the predicate holds', () => {
|
|
14
|
+
expect(
|
|
15
|
+
evaluateMatch(compileMatch('event == "pull_request" && body.action == "closed" && body.pull_request.merged'), ctx),
|
|
16
|
+
).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is false when the predicate does not hold', () => {
|
|
20
|
+
expect(evaluateMatch(compileMatch('body.action == "opened"'), ctx)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('reads headers', () => {
|
|
24
|
+
expect(evaluateMatch(compileMatch('headers["x-github-event"] == "pull_request"'), ctx)).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// The library returns a CelError value for a missing field rather than
|
|
28
|
+
// throwing or returning false. A merged-PR filter therefore errors on every
|
|
29
|
+
// issues delivery, which is ordinary and must read as "no match".
|
|
30
|
+
it('treats a missing field as no match, not an error and not a fire', () => {
|
|
31
|
+
const m = compileMatch('body.pull_request.merged')
|
|
32
|
+
expect(evaluateMatch(m, { ...ctx, body: { action: 'opened' } })).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('supports has() so an operator can guard explicitly', () => {
|
|
36
|
+
const m = compileMatch('has(body.pull_request) && body.pull_request.merged')
|
|
37
|
+
expect(evaluateMatch(m, { ...ctx, body: { action: 'opened' } })).toBe(false)
|
|
38
|
+
expect(evaluateMatch(m, ctx)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Fail closed: only an actual boolean true fires. A non-boolean result must
|
|
42
|
+
// never be coerced.
|
|
43
|
+
it('does not fire on a truthy non-boolean result', () => {
|
|
44
|
+
expect(evaluateMatch(compileMatch('body.action'), ctx)).toBe(false)
|
|
45
|
+
expect(evaluateMatch(compileMatch('body.number'), ctx)).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('does not fire on an undeclared variable', () => {
|
|
49
|
+
expect(evaluateMatch(compileMatch('nosuchthing == 1'), ctx)).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('compileMatch', () => {
|
|
54
|
+
it('rejects a syntax error at compile time, so a typo fails the daemon at boot', () => {
|
|
55
|
+
expect(() => compileMatch('body.action ==')).toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('accepts a valid expression', () => {
|
|
59
|
+
expect(() => compileMatch('body.action == "opened"')).not.toThrow()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parse, run, type CelInput } from '@bufbuild/cel'
|
|
2
|
+
|
|
3
|
+
export interface MatchContext {
|
|
4
|
+
event: string | undefined
|
|
5
|
+
body: unknown
|
|
6
|
+
headers: Record<string, string>
|
|
7
|
+
output: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse-check an expression. Throws on a syntax error, so a typo fails at config load. */
|
|
11
|
+
export function compileMatch(expr: string): string {
|
|
12
|
+
parse(expr)
|
|
13
|
+
return expr
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Only an actual `true` fires. Everything else is "no match":
|
|
18
|
+
* - a CelError value (missing field, undeclared variable) — the library
|
|
19
|
+
* returns these rather than throwing, and a merged-PR filter legitimately
|
|
20
|
+
* errors on every issues delivery;
|
|
21
|
+
* - a truthy non-boolean (a string, a number), which must never be coerced;
|
|
22
|
+
* - a throw.
|
|
23
|
+
* Failing closed matters more here than anywhere else in the ingress: this is
|
|
24
|
+
* the only place an operator's typo could otherwise open a filter.
|
|
25
|
+
*/
|
|
26
|
+
export function evaluateMatch(expr: string, ctx: MatchContext): boolean {
|
|
27
|
+
try {
|
|
28
|
+
const result = run(expr, { ...ctx } as Record<string, CelInput>)
|
|
29
|
+
return result === true
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderTemplate } from './render-template.js'
|
|
3
|
+
|
|
4
|
+
const ctx = {
|
|
5
|
+
event: 'issues',
|
|
6
|
+
body: { issue: { number: 23 }, repository: { full_name: 'zooid-ai/zooid' } },
|
|
7
|
+
headers: {},
|
|
8
|
+
output: '{\n "issue": {}\n}',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('renderTemplate', () => {
|
|
12
|
+
it('interpolates an expression', () => {
|
|
13
|
+
expect(renderTemplate('Triage ${body.repository.full_name}#${body.issue.number}.', ctx)).toBe(
|
|
14
|
+
'Triage zooid-ai/zooid#23.',
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('leaves text with no placeholders alone', () => {
|
|
19
|
+
expect(renderTemplate('A PR merged.', ctx)).toBe('A PR merged.')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// ${output} is not a special case: it is a bound variable like any other, so
|
|
23
|
+
// a whole-payload dump still works for senders we control ([[ZOD081]] §2).
|
|
24
|
+
it('still supports ${output}', () => {
|
|
25
|
+
expect(renderTemplate('Payload:\n${output}', ctx)).toContain('"issue"')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// A bad placeholder must not take the message down, and must not silently
|
|
29
|
+
// paste an error object into a room.
|
|
30
|
+
it('renders an unresolvable placeholder as empty rather than throwing', () => {
|
|
31
|
+
expect(renderTemplate('x${body.nope.deep}y', ctx)).toBe('xy')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('does not re-scan substituted content, so a payload cannot inject a placeholder', () => {
|
|
35
|
+
const evil = { ...ctx, body: { title: '${body.secret}' }, }
|
|
36
|
+
expect(renderTemplate('${body.title}', evil)).toBe('${body.secret}')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { run, isCelError, type CelInput } from '@bufbuild/cel'
|
|
2
|
+
import type { MatchContext } from './match-expression.js'
|
|
3
|
+
|
|
4
|
+
// Non-greedy so `${a}...${b}` is two placeholders, not one spanning both.
|
|
5
|
+
const PLACEHOLDER_RE = /\$\{([\s\S]+?)\}/g
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fill `${...}` placeholders in a trigger's `text:`. Each placeholder is a
|
|
9
|
+
* CEL expression over the same bindings `match:` sees — one expression
|
|
10
|
+
* language, not two. `${output}` is not a special case: `output` is just
|
|
11
|
+
* another bound variable, so a whole-payload dump still works for senders we
|
|
12
|
+
* control ([[ZOD081]] §2).
|
|
13
|
+
*
|
|
14
|
+
* An unresolvable placeholder (missing field, bad expression) renders as
|
|
15
|
+
* empty rather than throwing or pasting an error object into a room — a bad
|
|
16
|
+
* placeholder must not take the message down.
|
|
17
|
+
*
|
|
18
|
+
* The result is never re-scanned, so a payload that itself contains the
|
|
19
|
+
* literal string `${...}` cannot inject a placeholder.
|
|
20
|
+
*/
|
|
21
|
+
export function renderTemplate(template: string, ctx: MatchContext): string {
|
|
22
|
+
return template.replace(PLACEHOLDER_RE, (_match, expr: string) => {
|
|
23
|
+
let result: unknown
|
|
24
|
+
try {
|
|
25
|
+
result = run(expr, { ...ctx } as Record<string, CelInput>)
|
|
26
|
+
} catch {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
if (result === undefined || isCelError(result)) return ''
|
|
30
|
+
return String(result)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Harness-independent delegated-task contracts ([[ZOD072]]). */
|
|
2
|
+
export interface TaskCallerRef {
|
|
3
|
+
agentName: string
|
|
4
|
+
channelId: string
|
|
5
|
+
threadRoot: string
|
|
6
|
+
sessionKey: string
|
|
7
|
+
}
|
|
8
|
+
export interface StartTaskSpec {
|
|
9
|
+
agent: string
|
|
10
|
+
prompt: string
|
|
11
|
+
}
|
|
12
|
+
export interface StartTasksInput {
|
|
13
|
+
tasks: StartTaskSpec[]
|
|
14
|
+
notify?: 'caller' | 'none'
|
|
15
|
+
}
|
|
16
|
+
export type StartTaskResult =
|
|
17
|
+
| { agent: string; status: 'started'; thread_id: string }
|
|
18
|
+
| {
|
|
19
|
+
agent: string
|
|
20
|
+
status: 'refused' | 'failed'
|
|
21
|
+
reason: string
|
|
22
|
+
attempt_id?: string
|
|
23
|
+
}
|
|
24
|
+
export interface StartTasksOutput {
|
|
25
|
+
results: StartTaskResult[]
|
|
26
|
+
notify: 'caller' | 'none'
|
|
27
|
+
/** Stated at the point of decision, where the model actually reads it. */
|
|
28
|
+
delivery: string
|
|
29
|
+
}
|
|
30
|
+
export interface CompleteTaskInput {
|
|
31
|
+
summary: string
|
|
32
|
+
}
|
|
33
|
+
export interface CompleteTaskOutput {
|
|
34
|
+
status: 'recorded' | 'already_recorded' | 'refused'
|
|
35
|
+
reason?: string
|
|
36
|
+
}
|
|
37
|
+
/** What this session is, so the surface can gate itself instead of refusing later. */
|
|
38
|
+
export interface TaskRole {
|
|
39
|
+
is_task_assignee: boolean
|
|
40
|
+
can_start_task_threads: boolean
|
|
41
|
+
}
|
|
42
|
+
export interface TaskActions {
|
|
43
|
+
startTasks(caller: TaskCallerRef, input: StartTasksInput): Promise<StartTasksOutput>
|
|
44
|
+
completeTask(caller: TaskCallerRef, input: CompleteTaskInput): Promise<CompleteTaskOutput>
|
|
45
|
+
describeRole(caller: TaskCallerRef): Promise<TaskRole>
|
|
46
|
+
}
|
|
47
|
+
/** One in-thread handoff inside a delegated task. */
|
|
48
|
+
export type InvocationState = 'outstanding' | 'returned' | 'cancelled'
|
|
49
|
+
export interface InvocationRecord {
|
|
50
|
+
invocationId: string
|
|
51
|
+
taskId: string
|
|
52
|
+
callerAgent: string
|
|
53
|
+
callerSessionKey: string
|
|
54
|
+
calleeAgent: string
|
|
55
|
+
callEventId?: string
|
|
56
|
+
calleeSessionKey?: string
|
|
57
|
+
state: InvocationState
|
|
58
|
+
}
|
|
59
|
+
/** Open human-input requests for a session. ZOD078 supplies the implementation. */
|
|
60
|
+
export interface PendingInputRegistry {
|
|
61
|
+
countFor(sessionKey: string): number
|
|
62
|
+
cancelFor(sessionKeys: string[]): void
|
|
63
|
+
}
|
|
64
|
+
export const NO_PENDING_INPUT: PendingInputRegistry = {
|
|
65
|
+
countFor: () => 0,
|
|
66
|
+
cancelFor: () => {},
|
|
67
|
+
}
|
|
68
|
+
export const THREAD_START_FIELD = 'dev.zooid.thread_start'
|
|
69
|
+
export const THREAD_RESULT_FIELD = 'dev.zooid.thread_result'
|
|
70
|
+
export interface ThreadStartContent {
|
|
71
|
+
version: 1
|
|
72
|
+
assignee: string
|
|
73
|
+
attempt_id: string
|
|
74
|
+
parent: { agent: string; thread_root: string; session_key: string }
|
|
75
|
+
notify: 'caller' | 'none'
|
|
76
|
+
}
|
|
77
|
+
export interface ThreadCompletion {
|
|
78
|
+
agent: string
|
|
79
|
+
thread_id: string
|
|
80
|
+
status: 'complete' | 'failed' | 'cancelled' | 'partial'
|
|
81
|
+
output?: { type: 'message'; text: string }
|
|
82
|
+
reason?: string
|
|
83
|
+
error?: string
|
|
84
|
+
}
|