@ixo/oracle-runtime-workers 0.12.0 → 0.15.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/README.md +32 -10
- package/package.json +14 -9
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +198 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +523 -111
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous feedback configuration, read from the Worker env by the shell.
|
|
3
|
+
*
|
|
4
|
+
* The keys are deliberately NOT part of the validated base env
|
|
5
|
+
* (`src/core/env.ts`), so they never appear in the `ctx.config` plugins
|
|
6
|
+
* receive, and `composeEnvSchema` refuses a plugin whose `configSchema`
|
|
7
|
+
* declares a `FEEDBACK_` key. That is the whole guarantee: a plugin HTTP
|
|
8
|
+
* route (`PluginRoute.handler`) is handed the raw Worker env, which holds
|
|
9
|
+
* these keys like every other binding and secret.
|
|
10
|
+
*
|
|
11
|
+
* Off unless both the Linear key and the HMAC secret are set. One without
|
|
12
|
+
* the other, or a malformed value, fails the boot: an operator who set half
|
|
13
|
+
* of it meant to turn the feature on.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { isSecureUrl } from '../artifacts/config';
|
|
17
|
+
import { DEFAULT_MODEL_ID } from '../core/llm';
|
|
18
|
+
import type { OracleWorkerEnv } from '../do/contracts';
|
|
19
|
+
import type { FeedbackIssue } from './contract';
|
|
20
|
+
|
|
21
|
+
/** Studio team and its "User Feedback from Portal" project. */
|
|
22
|
+
export const DEFAULT_FEEDBACK_LINEAR_TEAM_ID =
|
|
23
|
+
'c781a53a-d432-469f-9c9c-2345a0f8243b';
|
|
24
|
+
export const DEFAULT_FEEDBACK_LINEAR_PROJECT_ID =
|
|
25
|
+
'6c1474a9-620c-4e3c-b443-0263992f3b55';
|
|
26
|
+
export const DEFAULT_FEEDBACK_LINEAR_API_URL = 'https://api.linear.app/graphql';
|
|
27
|
+
export const FEEDBACK_HMAC_SECRET_MIN_CHARS = 32;
|
|
28
|
+
|
|
29
|
+
const linearId = z.uuid();
|
|
30
|
+
|
|
31
|
+
const FeedbackEnv = z.object({
|
|
32
|
+
FEEDBACK_LINEAR_API_KEY: z.string().trim().min(1),
|
|
33
|
+
FEEDBACK_HMAC_SECRET: z.string().min(FEEDBACK_HMAC_SECRET_MIN_CHARS),
|
|
34
|
+
FEEDBACK_LINEAR_TEAM_ID: linearId.default(DEFAULT_FEEDBACK_LINEAR_TEAM_ID),
|
|
35
|
+
FEEDBACK_LINEAR_PROJECT_ID: linearId.default(
|
|
36
|
+
DEFAULT_FEEDBACK_LINEAR_PROJECT_ID,
|
|
37
|
+
),
|
|
38
|
+
FEEDBACK_LINEAR_LABEL_IDS: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.transform((raw) =>
|
|
42
|
+
(raw ?? '')
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((id) => id.trim())
|
|
45
|
+
.filter(Boolean),
|
|
46
|
+
)
|
|
47
|
+
.pipe(z.array(linearId)),
|
|
48
|
+
FEEDBACK_LINEAR_API_URL: z
|
|
49
|
+
.string()
|
|
50
|
+
.refine(isSecureUrl, 'must be an https URL (http only on localhost)')
|
|
51
|
+
.default(DEFAULT_FEEDBACK_LINEAR_API_URL),
|
|
52
|
+
QIFORGE_BUILD_VERSION: z.string().trim().min(1).max(80).optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export interface LinearDestination {
|
|
56
|
+
apiUrl: string;
|
|
57
|
+
apiKey: string;
|
|
58
|
+
teamId: string;
|
|
59
|
+
projectId: string;
|
|
60
|
+
labelIds: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FeedbackConfig {
|
|
64
|
+
hmacSecret: string;
|
|
65
|
+
linear: LinearDestination;
|
|
66
|
+
/** The coarse Agent context every issue carries. */
|
|
67
|
+
agent: FeedbackIssue['agent'];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type FeedbackEnvSource = Pick<
|
|
71
|
+
OracleWorkerEnv,
|
|
72
|
+
| 'ORACLE_NAME'
|
|
73
|
+
| 'ORACLE_DID'
|
|
74
|
+
| 'ORACLE_ENTITY_DID'
|
|
75
|
+
| 'DEFAULT_MODEL'
|
|
76
|
+
| 'LLM_PROVIDER'
|
|
77
|
+
| 'FEEDBACK_LINEAR_API_KEY'
|
|
78
|
+
| 'FEEDBACK_HMAC_SECRET'
|
|
79
|
+
| 'FEEDBACK_LINEAR_TEAM_ID'
|
|
80
|
+
| 'FEEDBACK_LINEAR_PROJECT_ID'
|
|
81
|
+
| 'FEEDBACK_LINEAR_LABEL_IDS'
|
|
82
|
+
| 'FEEDBACK_LINEAR_API_URL'
|
|
83
|
+
| 'QIFORGE_BUILD_VERSION'
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
const blank = (value: string | undefined) => !value || !value.trim();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Null when the feature is off. Throws when it is half-configured or a
|
|
90
|
+
* value is malformed; the message names the keys, never their values.
|
|
91
|
+
*/
|
|
92
|
+
export function feedbackConfigFromEnv(
|
|
93
|
+
env: FeedbackEnvSource,
|
|
94
|
+
): FeedbackConfig | null {
|
|
95
|
+
const keyMissing = blank(env.FEEDBACK_LINEAR_API_KEY);
|
|
96
|
+
const secretMissing = blank(env.FEEDBACK_HMAC_SECRET);
|
|
97
|
+
if (keyMissing && secretMissing) return null;
|
|
98
|
+
if (keyMissing || secretMissing)
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Anonymous feedback is half-configured: set both FEEDBACK_LINEAR_API_KEY and FEEDBACK_HMAC_SECRET, or neither (missing: ${keyMissing ? 'FEEDBACK_LINEAR_API_KEY' : 'FEEDBACK_HMAC_SECRET'})`,
|
|
101
|
+
);
|
|
102
|
+
const parsed = FeedbackEnv.safeParse(env);
|
|
103
|
+
if (!parsed.success)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Anonymous feedback configuration is invalid: ${parsed.error.issues
|
|
106
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
107
|
+
.join('; ')}`,
|
|
108
|
+
);
|
|
109
|
+
const config = parsed.data;
|
|
110
|
+
return {
|
|
111
|
+
hmacSecret: config.FEEDBACK_HMAC_SECRET,
|
|
112
|
+
linear: {
|
|
113
|
+
apiUrl: config.FEEDBACK_LINEAR_API_URL,
|
|
114
|
+
apiKey: config.FEEDBACK_LINEAR_API_KEY,
|
|
115
|
+
teamId: config.FEEDBACK_LINEAR_TEAM_ID,
|
|
116
|
+
projectId: config.FEEDBACK_LINEAR_PROJECT_ID,
|
|
117
|
+
labelIds: config.FEEDBACK_LINEAR_LABEL_IDS,
|
|
118
|
+
},
|
|
119
|
+
agent: {
|
|
120
|
+
did: env.ORACLE_ENTITY_DID || env.ORACLE_DID,
|
|
121
|
+
name: env.ORACLE_NAME,
|
|
122
|
+
model: env.DEFAULT_MODEL || DEFAULT_MODEL_ID,
|
|
123
|
+
provider: env.LLM_PROVIDER ?? 'openrouter',
|
|
124
|
+
runtimeBuildVersion: config.QIFORGE_BUILD_VERSION ?? 'unknown',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous Agent-response feedback: the wire contract of
|
|
3
|
+
* `POST /messages/:sessionId/:messageId/feedback`, the issue handed to a
|
|
4
|
+
* sink, and the user object's reservation outcomes.
|
|
5
|
+
*
|
|
6
|
+
* The request carries free text plus a small allowlisted context. Nothing
|
|
7
|
+
* else a client sends is read, so no prompt, response, tool data, URL, user
|
|
8
|
+
* agent or location can reach the sink through this route.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The env prefix reserved for this feature (its Linear key and pseudonym
|
|
14
|
+
* secret): `composeEnvSchema` refuses a plugin that declares a key under it.
|
|
15
|
+
*/
|
|
16
|
+
export const FEEDBACK_ENV_PREFIX = 'FEEDBACK_';
|
|
17
|
+
|
|
18
|
+
export const FEEDBACK_TEXT_MAX_CHARS = 2000;
|
|
19
|
+
/** Generous for 2000 characters of text plus the context; anything larger is not a feedback form. */
|
|
20
|
+
export const FEEDBACK_BODY_MAX_BYTES = 16 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Language and optional region only (`en`, `en-GB`, `es-419`) — no variants or extensions. */
|
|
23
|
+
const LANGUAGE_REGION = /^[a-z]{2,3}(?:-(?:[A-Z]{2}|\d{3}))?$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A BCP 47 tag reduced to its canonical form, accepted only as language plus
|
|
27
|
+
* optional region. Variants and private-use subtags are free text a client
|
|
28
|
+
* could fill with a name or a place, so they are refused.
|
|
29
|
+
*/
|
|
30
|
+
const Locale = z
|
|
31
|
+
.string()
|
|
32
|
+
.max(35)
|
|
33
|
+
.transform((raw, ctx) => {
|
|
34
|
+
let canonical: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
canonical = Intl.getCanonicalLocales(raw)[0];
|
|
37
|
+
} catch {
|
|
38
|
+
canonical = undefined;
|
|
39
|
+
}
|
|
40
|
+
if (canonical === undefined || !LANGUAGE_REGION.test(canonical)) {
|
|
41
|
+
ctx.addIssue({
|
|
42
|
+
code: 'custom',
|
|
43
|
+
message: 'locale must be a language with an optional region',
|
|
44
|
+
});
|
|
45
|
+
return z.NEVER;
|
|
46
|
+
}
|
|
47
|
+
return canonical;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A release (`1.4.0`, `1.5.0-rc.2`, `1.4.0+4f2ea36`) or a commit (`4f2ea36`).
|
|
52
|
+
* Pre-release labels are a fixed list so the field cannot carry free text.
|
|
53
|
+
*/
|
|
54
|
+
const PORTAL_BUILD =
|
|
55
|
+
/^(?:\d+\.\d+\.\d+(?:-(?:alpha|beta|rc|next|canary)(?:\.\d+)*)?(?:\+[0-9a-f]{7,12})?|[0-9a-f]{7,40})$/;
|
|
56
|
+
|
|
57
|
+
export const FeedbackContext = z
|
|
58
|
+
.object({
|
|
59
|
+
surface: z.enum(['workspace', 'agentSidebar']),
|
|
60
|
+
locale: Locale,
|
|
61
|
+
theme: z.enum(['dark', 'light']),
|
|
62
|
+
deviceClass: z.enum(['mobile', 'tablet', 'desktop']),
|
|
63
|
+
viewportBucket: z.enum(['compact', 'medium', 'wide']),
|
|
64
|
+
network: z.enum(['mainnet', 'testnet', 'devnet', 'unknown']),
|
|
65
|
+
portalBuildVersion: z.string().max(40).regex(PORTAL_BUILD).optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
68
|
+
export type FeedbackContext = z.output<typeof FeedbackContext>;
|
|
69
|
+
|
|
70
|
+
export const FeedbackSubmission = z
|
|
71
|
+
.object({
|
|
72
|
+
/** Client-generated idempotency key: a retry of one submission reuses it. */
|
|
73
|
+
submissionId: z.uuidv4(),
|
|
74
|
+
feedback: z.string().min(1).max(FEEDBACK_TEXT_MAX_CHARS),
|
|
75
|
+
context: FeedbackContext,
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
78
|
+
export type FeedbackSubmission = z.infer<typeof FeedbackSubmission>;
|
|
79
|
+
|
|
80
|
+
export interface FeedbackResponse {
|
|
81
|
+
submissionId: string;
|
|
82
|
+
status: 'submitted';
|
|
83
|
+
submittedAt: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Everything a sink receives. Ids are keyed pseudonyms, never raw values. */
|
|
87
|
+
export interface FeedbackIssue {
|
|
88
|
+
submissionId: string;
|
|
89
|
+
/** The screened text — the only user-authored content. */
|
|
90
|
+
feedback: string;
|
|
91
|
+
submittedAt: string;
|
|
92
|
+
userPseudonym: string;
|
|
93
|
+
sessionFingerprint: string;
|
|
94
|
+
/** Also the sink's idempotency marker: one issue per user message. */
|
|
95
|
+
messageFingerprint: string;
|
|
96
|
+
agent: {
|
|
97
|
+
did: string;
|
|
98
|
+
name: string;
|
|
99
|
+
model: string;
|
|
100
|
+
provider: string;
|
|
101
|
+
runtimeBuildVersion: string;
|
|
102
|
+
};
|
|
103
|
+
context: FeedbackContext;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `created`: this call made the issue. `existing`: an issue for the message
|
|
108
|
+
* was already there, so this call sent nothing.
|
|
109
|
+
*/
|
|
110
|
+
export type FeedbackDelivery = 'created' | 'existing';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where feedback goes. The runtime ships a Linear sink; tests inject a fake.
|
|
114
|
+
* `submit` must be idempotent per `messageFingerprint`, say whether it
|
|
115
|
+
* created the issue, and throw when the outcome was not confirmed.
|
|
116
|
+
*/
|
|
117
|
+
export interface FeedbackSink {
|
|
118
|
+
submit(issue: FeedbackIssue): Promise<FeedbackDelivery>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How the shell settles a reservation (see `FeedbackMarkers.settle`). */
|
|
122
|
+
export type FeedbackSettlement = 'delivered' | 'superseded' | 'released';
|
|
123
|
+
|
|
124
|
+
/** The message a submission is about, as the shell names it to the user object. */
|
|
125
|
+
export interface FeedbackTarget {
|
|
126
|
+
sessionId: string;
|
|
127
|
+
messageId: string;
|
|
128
|
+
submissionId: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the user object answers when the shell asks to submit feedback:
|
|
133
|
+
* - `reserved`: deliver now, then settle the reservation.
|
|
134
|
+
* `replacesOtherSubmission` is true when it took over a stale reservation
|
|
135
|
+
* of a different submission, which may already have created the issue;
|
|
136
|
+
* - `delivered`: this submission already went out — answer with it again;
|
|
137
|
+
* - `in_flight`: this same submission is being delivered right now;
|
|
138
|
+
* - `conflict`: different feedback for the message was delivered or is in flight;
|
|
139
|
+
* - `not_found`: no such session or message, or not a completed Agent reply;
|
|
140
|
+
* - `rate_limited`: the user's own submission limit.
|
|
141
|
+
*/
|
|
142
|
+
export type FeedbackReservation =
|
|
143
|
+
| { kind: 'reserved'; submittedAt: string; replacesOtherSubmission: boolean }
|
|
144
|
+
| { kind: 'delivered'; submittedAt: string }
|
|
145
|
+
| { kind: 'in_flight' }
|
|
146
|
+
| { kind: 'conflict' }
|
|
147
|
+
| { kind: 'not_found' }
|
|
148
|
+
| { kind: 'rate_limited' };
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linear feedback sink: one issue per (user, message) in the configured
|
|
3
|
+
* team and project, over Linear's GraphQL API with `fetch`.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent across retries and Worker restarts: the issue description
|
|
6
|
+
* carries the message fingerprint, and the sink looks for it before every
|
|
7
|
+
* create and again before retrying a create whose outcome is unknown.
|
|
8
|
+
* Bounded: at most three attempts per call, waits honour Linear's
|
|
9
|
+
* `X-RateLimit-*-Reset` headers (UTC epoch milliseconds) and give up rather
|
|
10
|
+
* than wait longer than {@link MAX_RETRY_WAIT_MS}.
|
|
11
|
+
*/
|
|
12
|
+
import type { LinearDestination } from './config';
|
|
13
|
+
import type { FeedbackDelivery, FeedbackIssue, FeedbackSink } from './contract';
|
|
14
|
+
|
|
15
|
+
const FIND_EXISTING_ISSUE = `
|
|
16
|
+
query AnonymousFeedbackIssue($projectId: ID!, $marker: String!) {
|
|
17
|
+
issues(
|
|
18
|
+
first: 1
|
|
19
|
+
filter: {
|
|
20
|
+
project: { id: { eq: $projectId } }
|
|
21
|
+
description: { contains: $marker }
|
|
22
|
+
}
|
|
23
|
+
) {
|
|
24
|
+
nodes { id }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const CREATE_FEEDBACK_ISSUE = `
|
|
30
|
+
mutation CreateAnonymousFeedbackIssue($input: IssueCreateInput!) {
|
|
31
|
+
issueCreate(input: $input) {
|
|
32
|
+
success
|
|
33
|
+
issue { id }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const MAX_ATTEMPTS = 3;
|
|
39
|
+
const MAX_RETRY_WAIT_MS = 2_000;
|
|
40
|
+
const REQUEST_TIMEOUT_MS = 8_000;
|
|
41
|
+
|
|
42
|
+
/** A failed Linear call. The message holds the status and error code only. */
|
|
43
|
+
export class LinearFeedbackError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
readonly retryable: boolean,
|
|
47
|
+
readonly retryAfterMs?: number,
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'LinearFeedbackError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface LinearGraphqlResponse<T> {
|
|
55
|
+
data?: T;
|
|
56
|
+
errors?: Array<{ extensions?: { code?: string } }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LinearSinkOptions {
|
|
60
|
+
fetch?: typeof fetch;
|
|
61
|
+
sleep?: (ms: number) => Promise<void>;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tableValue(value: string): string {
|
|
66
|
+
return value.replace(/[\r\n|]/g, ' ').trim() || 'unknown';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The user's text as a fenced block, so Linear renders it verbatim: no
|
|
71
|
+
* headings, links or images (an image would fetch from a URL the author
|
|
72
|
+
* chose when a reviewer opens the issue), no fake context table. The fence
|
|
73
|
+
* is one backtick longer than any run in the text, so the text cannot close
|
|
74
|
+
* it.
|
|
75
|
+
*/
|
|
76
|
+
function fencedText(text: string): string {
|
|
77
|
+
const longestRun = Math.max(
|
|
78
|
+
2,
|
|
79
|
+
...Array.from(text.matchAll(/`+/g), (m) => m[0].length),
|
|
80
|
+
);
|
|
81
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
82
|
+
return `${fence}text\n${text}\n${fence}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How long a RATE-LIMITED response asks us to wait. Linear sends the
|
|
87
|
+
* `X-RateLimit-*-Reset` headers on every response, so they say when the
|
|
88
|
+
* window resets, not when to retry a failure: they are only read for a
|
|
89
|
+
* rate limit (HTTP 429 or GraphQL `RATELIMITED`). Other failures back off.
|
|
90
|
+
*/
|
|
91
|
+
function rateLimitDelayMs(response: Response, now: number): number | undefined {
|
|
92
|
+
const retryAfter = response.headers.get('retry-after');
|
|
93
|
+
if (retryAfter) {
|
|
94
|
+
const seconds = Number(retryAfter);
|
|
95
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
96
|
+
const date = Date.parse(retryAfter);
|
|
97
|
+
if (Number.isFinite(date)) return Math.max(0, date - now);
|
|
98
|
+
}
|
|
99
|
+
const reset =
|
|
100
|
+
response.headers.get('x-ratelimit-endpoint-requests-reset') ??
|
|
101
|
+
response.headers.get('x-ratelimit-requests-reset') ??
|
|
102
|
+
response.headers.get('x-ratelimit-complexity-reset');
|
|
103
|
+
const resetAt = reset ? Number(reset) : Number.NaN;
|
|
104
|
+
return Number.isFinite(resetAt) ? Math.max(0, resetAt - now) : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function linearFeedbackTitle(issue: FeedbackIssue): string {
|
|
108
|
+
const at = new Date(issue.submittedAt)
|
|
109
|
+
.toISOString()
|
|
110
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
111
|
+
return `[Agent feedback] ${issue.context.surface} · ${at}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The issue body: the screened feedback, then a table of allowlisted coarse
|
|
116
|
+
* context and keyed pseudonyms. Nothing else about the user or the
|
|
117
|
+
* conversation is available to this function.
|
|
118
|
+
*/
|
|
119
|
+
export function linearFeedbackDescription(issue: FeedbackIssue): string {
|
|
120
|
+
const rows: Array<[string, string]> = [
|
|
121
|
+
['Submission ID', issue.submissionId],
|
|
122
|
+
['User pseudonym', issue.userPseudonym],
|
|
123
|
+
['Session fingerprint', issue.sessionFingerprint],
|
|
124
|
+
['Message fingerprint', issue.messageFingerprint],
|
|
125
|
+
['Agent DID', issue.agent.did],
|
|
126
|
+
['Agent name', issue.agent.name],
|
|
127
|
+
['Default model', issue.agent.model],
|
|
128
|
+
['Provider', issue.agent.provider],
|
|
129
|
+
['Surface', issue.context.surface],
|
|
130
|
+
['Network', issue.context.network],
|
|
131
|
+
['Locale', issue.context.locale],
|
|
132
|
+
['Theme', issue.context.theme],
|
|
133
|
+
['Device class', issue.context.deviceClass],
|
|
134
|
+
['Viewport', issue.context.viewportBucket],
|
|
135
|
+
['Portal build', issue.context.portalBuildVersion ?? 'unknown'],
|
|
136
|
+
['QiForge build', issue.agent.runtimeBuildVersion],
|
|
137
|
+
['Submitted at', issue.submittedAt],
|
|
138
|
+
];
|
|
139
|
+
return [
|
|
140
|
+
'## Feedback',
|
|
141
|
+
'',
|
|
142
|
+
fencedText(issue.feedback),
|
|
143
|
+
'',
|
|
144
|
+
'## Safe context',
|
|
145
|
+
'',
|
|
146
|
+
'| Field | Value |',
|
|
147
|
+
'| --- | --- |',
|
|
148
|
+
...rows.map(([label, value]) => `| ${label} | ${tableValue(value)} |`),
|
|
149
|
+
'',
|
|
150
|
+
'## Privacy',
|
|
151
|
+
'',
|
|
152
|
+
'No prompt, response, reasoning, tool data, attachment, raw DID, raw session ID, raw message ID, IP address, URL, payment data, location or full user agent was attached. User and conversation references are keyed pseudonyms.',
|
|
153
|
+
].join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class LinearFeedbackSink implements FeedbackSink {
|
|
157
|
+
private readonly fetch: typeof fetch;
|
|
158
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
159
|
+
private readonly now: () => number;
|
|
160
|
+
|
|
161
|
+
constructor(
|
|
162
|
+
private readonly destination: LinearDestination,
|
|
163
|
+
opts: LinearSinkOptions = {},
|
|
164
|
+
) {
|
|
165
|
+
this.fetch = opts.fetch ?? ((input, init) => fetch(input, init));
|
|
166
|
+
this.sleep =
|
|
167
|
+
opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
168
|
+
this.now = opts.now ?? Date.now;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async submit(issue: FeedbackIssue): Promise<FeedbackDelivery> {
|
|
172
|
+
const marker = issue.messageFingerprint;
|
|
173
|
+
if (await this.issueExists(marker)) return 'existing';
|
|
174
|
+
const { teamId, projectId, labelIds } = this.destination;
|
|
175
|
+
const input = {
|
|
176
|
+
teamId,
|
|
177
|
+
projectId,
|
|
178
|
+
title: linearFeedbackTitle(issue),
|
|
179
|
+
description: linearFeedbackDescription(issue),
|
|
180
|
+
...(labelIds.length > 0 && { labelIds }),
|
|
181
|
+
};
|
|
182
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
183
|
+
try {
|
|
184
|
+
const created = await this.requestOnce<{
|
|
185
|
+
issueCreate: { success: boolean; issue?: { id: string } | null };
|
|
186
|
+
}>(CREATE_FEEDBACK_ISSUE, { input });
|
|
187
|
+
if (!created.issueCreate.success || !created.issueCreate.issue?.id)
|
|
188
|
+
throw new LinearFeedbackError(
|
|
189
|
+
'Linear did not confirm the feedback issue',
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
return 'created';
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (
|
|
195
|
+
!(error instanceof LinearFeedbackError) ||
|
|
196
|
+
!error.retryable ||
|
|
197
|
+
attempt === MAX_ATTEMPTS - 1
|
|
198
|
+
)
|
|
199
|
+
throw error;
|
|
200
|
+
await this.waitBeforeRetry(error, attempt);
|
|
201
|
+
// The failed create may have landed: never create a second issue.
|
|
202
|
+
// It was this call's create, so it counts as created.
|
|
203
|
+
if (await this.issueExists(marker)) return 'created';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async issueExists(marker: string): Promise<boolean> {
|
|
209
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
210
|
+
try {
|
|
211
|
+
const found = await this.requestOnce<{
|
|
212
|
+
issues: { nodes: Array<{ id: string }> };
|
|
213
|
+
}>(FIND_EXISTING_ISSUE, {
|
|
214
|
+
projectId: this.destination.projectId,
|
|
215
|
+
marker,
|
|
216
|
+
});
|
|
217
|
+
return found.issues.nodes.length > 0;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (
|
|
220
|
+
!(error instanceof LinearFeedbackError) ||
|
|
221
|
+
!error.retryable ||
|
|
222
|
+
attempt === MAX_ATTEMPTS - 1
|
|
223
|
+
)
|
|
224
|
+
throw error;
|
|
225
|
+
await this.waitBeforeRetry(error, attempt);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async waitBeforeRetry(
|
|
231
|
+
error: LinearFeedbackError,
|
|
232
|
+
attempt: number,
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
const delay = error.retryAfterMs ?? 250 * 2 ** attempt;
|
|
235
|
+
if (delay > MAX_RETRY_WAIT_MS) throw error;
|
|
236
|
+
await this.sleep(delay);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async requestOnce<T>(
|
|
240
|
+
query: string,
|
|
241
|
+
variables: Record<string, unknown>,
|
|
242
|
+
): Promise<T> {
|
|
243
|
+
let response: Response;
|
|
244
|
+
try {
|
|
245
|
+
response = await this.fetch(this.destination.apiUrl, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
// Linear personal API keys go in the header as-is (no Bearer prefix).
|
|
248
|
+
headers: {
|
|
249
|
+
authorization: this.destination.apiKey,
|
|
250
|
+
'content-type': 'application/json',
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify({ query, variables }),
|
|
253
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw new LinearFeedbackError(
|
|
257
|
+
error instanceof Error &&
|
|
258
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
259
|
+
? 'Linear feedback request timed out'
|
|
260
|
+
: 'Linear feedback request failed (network)',
|
|
261
|
+
true,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const payload: LinearGraphqlResponse<T> | null = await response
|
|
265
|
+
.json<LinearGraphqlResponse<T>>()
|
|
266
|
+
.catch(() => null);
|
|
267
|
+
const code = payload?.errors?.[0]?.extensions?.code;
|
|
268
|
+
if (!response.ok || payload?.errors?.length) {
|
|
269
|
+
const rateLimited = response.status === 429 || code === 'RATELIMITED';
|
|
270
|
+
throw new LinearFeedbackError(
|
|
271
|
+
`Linear feedback request failed (${response.status}${code ? `/${code}` : ''})`,
|
|
272
|
+
rateLimited ||
|
|
273
|
+
response.status >= 500 ||
|
|
274
|
+
code === 'INTERNAL_SERVER_ERROR',
|
|
275
|
+
rateLimited ? rateLimitDelayMs(response, this.now()) : undefined,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (!payload?.data)
|
|
279
|
+
throw new LinearFeedbackError(
|
|
280
|
+
'Linear feedback request returned no data',
|
|
281
|
+
false,
|
|
282
|
+
);
|
|
283
|
+
return payload.data;
|
|
284
|
+
}
|
|
285
|
+
}
|