okengine 0.6.0 → 0.7.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/AGENTS.md +2 -2
- package/README.md +98 -71
- package/package.json +4 -4
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +90 -20
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +18 -17
- package/site/content/docs/reference/environment-variables.mdx +44 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +7 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/index.ts +9 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ImageRecipe } from "../types.ts";
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Start serve, pull configured model, keep serve in foreground.
|
|
13
|
+
*
|
|
14
|
+
* Shell `$` must be written as `$$` so Compose does not interpolate `pid` /
|
|
15
|
+
* `i` as project env (same pattern as the redis recipe).
|
|
16
|
+
*/
|
|
12
17
|
const OLLAMA_BOOT = [
|
|
13
18
|
"set -e",
|
|
14
19
|
"/bin/ollama serve &",
|
|
15
|
-
"pid
|
|
16
|
-
'i=0; until /bin/ollama list >/dev/null 2>&1; do i
|
|
17
|
-
'/bin/ollama pull "
|
|
18
|
-
"wait
|
|
20
|
+
"pid=$$!",
|
|
21
|
+
'i=0; until /bin/ollama list >/dev/null 2>&1; do i=$$((i+1)); [ "$$i" -lt 90 ] || exit 1; sleep 1; done',
|
|
22
|
+
'/bin/ollama pull "$${OKE_AI_MODEL:-qwen3.5:9b}"',
|
|
23
|
+
"wait $$pid",
|
|
19
24
|
].join("; ");
|
|
20
25
|
|
|
21
26
|
/** Ollama local model server. API on 11434. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration: generated compose brings up postgres; app URL talks to it.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon. Never an empty pass.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
import { describe, expect, test } from "bun:test";
|
|
@@ -8,127 +10,141 @@ import { tmpdir } from "node:os";
|
|
|
8
10
|
import { join } from "node:path";
|
|
9
11
|
import { deriveInfrastructure, formatStackEnv, writeDerivedFiles } from "./index.ts";
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
function dockerAvailable(): boolean {
|
|
12
14
|
try {
|
|
13
|
-
|
|
14
|
-
stdout: "pipe",
|
|
15
|
-
stderr: "pipe",
|
|
16
|
-
});
|
|
17
|
-
return (await proc.exited) === 0;
|
|
15
|
+
return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
|
|
18
16
|
} catch {
|
|
19
17
|
return false;
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const derived = deriveInfrastructure({
|
|
35
|
-
images: { "store.sql": "postgres:18-alpine" },
|
|
36
|
-
credentials: {
|
|
37
|
-
"store.sql": {
|
|
38
|
-
user: "oke",
|
|
39
|
-
password: "stack-integration-pass",
|
|
40
|
-
database: "oke",
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
app: "stacktest",
|
|
44
|
-
host: "127.0.0.1",
|
|
45
|
-
includeApp: false,
|
|
46
|
-
composeDir: "docker",
|
|
47
|
-
});
|
|
48
|
-
await writeDerivedFiles(derived, dockerDir, {
|
|
49
|
-
writeStackEnv: true,
|
|
50
|
-
});
|
|
21
|
+
const WANT = process.env.OKE_TEST_DOCKER === "1";
|
|
22
|
+
const DOCKER = WANT && dockerAvailable();
|
|
23
|
+
if (!DOCKER) {
|
|
24
|
+
console.log(
|
|
25
|
+
WANT
|
|
26
|
+
? "skip: docker postgres stack e2e (docker daemon not available)"
|
|
27
|
+
: "skip: docker postgres stack e2e (OKE_TEST_DOCKER≠1)",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const live = DOCKER ? test : test.skip;
|
|
51
31
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
32
|
+
describe("oke dev --docker postgres integration", () => {
|
|
33
|
+
live(
|
|
34
|
+
"compose brings up postgres and the app talks to it",
|
|
35
|
+
async () => {
|
|
36
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
|
|
37
|
+
const dockerDir = join(dir, "docker");
|
|
38
|
+
const project = `oke-pg-${Date.now()}`;
|
|
39
|
+
try {
|
|
40
|
+
const derived = deriveInfrastructure({
|
|
41
|
+
images: { "store.sql": "postgres:18-alpine" },
|
|
42
|
+
credentials: {
|
|
43
|
+
"store.sql": {
|
|
44
|
+
user: "oke",
|
|
45
|
+
password: "stack-integration-pass",
|
|
46
|
+
database: "oke",
|
|
47
|
+
},
|
|
63
48
|
},
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (upCode !== 0) console.error(upOut, upErr);
|
|
49
|
+
app: "stacktest",
|
|
50
|
+
host: "127.0.0.1",
|
|
51
|
+
includeApp: false,
|
|
52
|
+
composeDir: "docker",
|
|
53
|
+
});
|
|
54
|
+
await writeDerivedFiles(derived, dockerDir, {
|
|
55
|
+
writeStackEnv: true,
|
|
56
|
+
});
|
|
73
57
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const ps = Bun.spawn(
|
|
58
|
+
// Infra-only: network + role compose (no app build).
|
|
59
|
+
const composeFiles = ["compose.yml", "compose.store.sql.yml"];
|
|
60
|
+
const up = Bun.spawn(
|
|
78
61
|
[
|
|
79
62
|
"docker",
|
|
80
63
|
"compose",
|
|
81
64
|
"-p",
|
|
82
65
|
project,
|
|
83
66
|
...composeFiles.flatMap((f) => ["-f", f]),
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"json",
|
|
67
|
+
"up",
|
|
68
|
+
"-d",
|
|
87
69
|
],
|
|
88
|
-
{
|
|
70
|
+
{
|
|
71
|
+
cwd: dockerDir,
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "pipe",
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
...derived.stackEnv,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
89
79
|
);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
80
|
+
const [upOut, upErr, upCode] = await Promise.all([
|
|
81
|
+
new Response(up.stdout).text(),
|
|
82
|
+
new Response(up.stderr).text(),
|
|
83
|
+
up.exited,
|
|
84
|
+
]);
|
|
85
|
+
expect(upCode).toBe(0);
|
|
86
|
+
if (upCode !== 0) console.error(upOut, upErr);
|
|
87
|
+
|
|
88
|
+
// Wait for healthy
|
|
89
|
+
let healthy = false;
|
|
90
|
+
for (let i = 0; i < 40; i++) {
|
|
91
|
+
const ps = Bun.spawn(
|
|
92
|
+
[
|
|
93
|
+
"docker",
|
|
94
|
+
"compose",
|
|
95
|
+
"-p",
|
|
96
|
+
project,
|
|
97
|
+
...composeFiles.flatMap((f) => ["-f", f]),
|
|
98
|
+
"ps",
|
|
99
|
+
"--format",
|
|
100
|
+
"json",
|
|
101
|
+
],
|
|
102
|
+
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
103
|
+
);
|
|
104
|
+
const text = await new Response(ps.stdout).text();
|
|
105
|
+
await ps.exited;
|
|
106
|
+
if (/healthy/i.test(text)) {
|
|
107
|
+
healthy = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
await Bun.sleep(500);
|
|
95
111
|
}
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
expect(healthy).toBe(true);
|
|
112
|
+
expect(healthy).toBe(true);
|
|
99
113
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
const url = derived.stackEnv.DATABASE_URL!;
|
|
115
|
+
// App talks to postgres via recipe URL (Bun.SQL) — kernel never sees env-var names.
|
|
116
|
+
const sql = new Bun.SQL(url);
|
|
117
|
+
try {
|
|
118
|
+
const rows = (await sql`select 1::int as n`) as Array<{ n: number }>;
|
|
119
|
+
expect(rows[0]?.n).toBe(1);
|
|
120
|
+
} finally {
|
|
121
|
+
await sql.close();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Prove credentials live in docker/.env.docker, not YAML.
|
|
125
|
+
const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
|
|
126
|
+
expect(yml).not.toContain("stack-integration-pass");
|
|
127
|
+
expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
|
|
128
|
+
expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
|
|
106
129
|
} finally {
|
|
107
|
-
await
|
|
130
|
+
await Bun.spawn(
|
|
131
|
+
[
|
|
132
|
+
"docker",
|
|
133
|
+
"compose",
|
|
134
|
+
"-p",
|
|
135
|
+
project,
|
|
136
|
+
"-f",
|
|
137
|
+
"compose.yml",
|
|
138
|
+
"-f",
|
|
139
|
+
"compose.store.sql.yml",
|
|
140
|
+
"down",
|
|
141
|
+
"-v",
|
|
142
|
+
],
|
|
143
|
+
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
144
|
+
).exited.catch(() => {});
|
|
145
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
108
146
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
expect(yml).not.toContain("stack-integration-pass");
|
|
113
|
-
expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
|
|
114
|
-
expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
|
|
115
|
-
} finally {
|
|
116
|
-
await Bun.spawn(
|
|
117
|
-
[
|
|
118
|
-
"docker",
|
|
119
|
-
"compose",
|
|
120
|
-
"-p",
|
|
121
|
-
project,
|
|
122
|
-
"-f",
|
|
123
|
-
"compose.yml",
|
|
124
|
-
"-f",
|
|
125
|
-
"compose.store.sql.yml",
|
|
126
|
-
"down",
|
|
127
|
-
"-v",
|
|
128
|
-
],
|
|
129
|
-
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
130
|
-
).exited.catch(() => {});
|
|
131
|
-
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
132
|
-
}
|
|
133
|
-
}, 120_000);
|
|
147
|
+
},
|
|
148
|
+
120_000,
|
|
149
|
+
);
|
|
134
150
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live Ollama tool-calling through fx.ask → fx.call (capability + ledger).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Opt-in via `OKE_TEST_OLLAMA_URL` (reachable Ollama). A local daemon on
|
|
5
|
+
* :11434 is not enough — set the URL explicitly. Never an empty pass.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { describe, expect, test } from "bun:test";
|
|
@@ -11,7 +12,6 @@ import { createGateRuntime, gate } from "../elements/gate.ts";
|
|
|
11
12
|
import { createFxContext } from "../kernel/fx.ts";
|
|
12
13
|
import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
|
|
13
14
|
|
|
14
|
-
const DEFAULT_LOCAL = "http://127.0.0.1:11434";
|
|
15
15
|
const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
|
|
16
16
|
|
|
17
17
|
async function probeOllama(url: string): Promise<boolean> {
|
|
@@ -23,12 +23,14 @@ async function probeOllama(url: string): Promise<boolean> {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
const canLive = Boolean(ENV_URL)
|
|
26
|
+
const reachable = ENV_URL ? await probeOllama(ENV_URL) : false;
|
|
27
|
+
const canLive = Boolean(ENV_URL) && reachable;
|
|
28
28
|
|
|
29
29
|
if (!canLive) {
|
|
30
30
|
console.log(
|
|
31
|
-
|
|
31
|
+
ENV_URL
|
|
32
|
+
? `skip: live ollama tool-calling e2e (unreachable at ${ENV_URL})`
|
|
33
|
+
: "skip: live ollama tool-calling e2e (OKE_TEST_OLLAMA_URL not set)",
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
36
|
const live = canLive ? test : test.skip;
|
|
@@ -37,7 +39,7 @@ describe("ollama live — tool-calling via fx.call", () => {
|
|
|
37
39
|
live(
|
|
38
40
|
"gated ask → real tool call → capability ledger portal entry",
|
|
39
41
|
async () => {
|
|
40
|
-
const url = ENV_URL
|
|
42
|
+
const url = ENV_URL!;
|
|
41
43
|
const model = process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL;
|
|
42
44
|
|
|
43
45
|
const kv = await memoryKvDriver.open({ name: "ollama-tools-rate" });
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Opt-in (never auto-pull a multi-GB model in ordinary `bun test`):
|
|
5
5
|
* 1. `OKE_TEST_OLLAMA_URL` — point at any reachable Ollama
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
|
|
6
|
+
* 2. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
|
|
8
7
|
* `qwen3.5:9b` (slow first run)
|
|
9
8
|
*
|
|
10
9
|
* Without one of those, the suite skips with a visible reason (never an empty pass).
|
|
10
|
+
* A local daemon on :11434 is not enough — set `OKE_TEST_OLLAMA_URL` explicitly.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
@@ -20,7 +20,6 @@ import { bindAi } from "../kernel/boot-bind/ai.ts";
|
|
|
20
20
|
import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
|
|
21
21
|
|
|
22
22
|
const OLLAMA_IMAGE = "ollama/ollama:latest";
|
|
23
|
-
const DEFAULT_LOCAL = "http://127.0.0.1:11434";
|
|
24
23
|
const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
|
|
25
24
|
const WANT_DOCKER = process.env.OKE_TEST_OLLAMA_DOCKER === "1";
|
|
26
25
|
|
|
@@ -32,23 +31,12 @@ function dockerAvailable(): boolean {
|
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
async function probeOllama(url: string): Promise<boolean> {
|
|
36
|
-
try {
|
|
37
|
-
const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(1_500) });
|
|
38
|
-
return res.ok;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
34
|
const DOCKER = dockerAvailable();
|
|
45
|
-
const
|
|
46
|
-
const canLive = Boolean(ENV_URL) || localUp || (WANT_DOCKER && DOCKER);
|
|
35
|
+
const canLive = Boolean(ENV_URL) || (WANT_DOCKER && DOCKER);
|
|
47
36
|
|
|
48
37
|
if (!canLive) {
|
|
49
38
|
const reasons: string[] = [];
|
|
50
39
|
if (!ENV_URL) reasons.push("OKE_TEST_OLLAMA_URL not set");
|
|
51
|
-
if (!localUp) reasons.push("no Ollama on :11434");
|
|
52
40
|
if (!WANT_DOCKER) reasons.push("OKE_TEST_OLLAMA_DOCKER≠1");
|
|
53
41
|
else if (!DOCKER) reasons.push("docker daemon not available");
|
|
54
42
|
console.log(`skip: live ollama e2e (${reasons.join("; ")})`);
|
|
@@ -94,10 +82,6 @@ async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
|
|
|
94
82
|
return { url: ENV_URL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
|
|
95
83
|
}
|
|
96
84
|
|
|
97
|
-
if (localUp) {
|
|
98
|
-
return { url: DEFAULT_LOCAL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
85
|
const dir = await mkdtemp(join(tmpdir(), "oke-ollama-"));
|
|
102
86
|
const dockerDir = join(dir, "docker");
|
|
103
87
|
const project = `oke-ollama-${Date.now()}`;
|
package/src/drivers/ai-ollama.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
* configured but unreachable throws {@link OllamaUnavailableError} — never a
|
|
8
8
|
* silent mock fallback.
|
|
9
9
|
*
|
|
10
|
-
* Native API: `POST /api/chat` (not the OpenAI-compat
|
|
11
|
-
* `http://127.0.0.1:11434`. Supports `complete`,
|
|
10
|
+
* Native API: `POST /api/chat` and `POST /api/embeddings` (not the OpenAI-compat
|
|
11
|
+
* shim). Default base URL `http://127.0.0.1:11434`. Supports `complete`,
|
|
12
|
+
* `stream` (NDJSON), `embed`, and tools.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import type {
|
|
15
16
|
AiCompleteOptions,
|
|
16
17
|
AiCompleteResult,
|
|
17
18
|
AiDriver,
|
|
19
|
+
AiEmbedOptions,
|
|
20
|
+
AiEmbedResult,
|
|
18
21
|
AiModelClient,
|
|
19
22
|
AiOpenOptions,
|
|
20
23
|
AiStreamChunk,
|
|
@@ -158,6 +161,41 @@ export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelCl
|
|
|
158
161
|
}
|
|
159
162
|
yield* readOllamaNdjson(res.body, opts.signal);
|
|
160
163
|
},
|
|
164
|
+
async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
|
|
165
|
+
const resolvedModel = resolveOllamaModel(options, opts.model);
|
|
166
|
+
const inputs = Array.isArray(opts.input) ? opts.input : [opts.input];
|
|
167
|
+
const vectors: number[][] = [];
|
|
168
|
+
|
|
169
|
+
for (const prompt of inputs) {
|
|
170
|
+
let res: Response;
|
|
171
|
+
try {
|
|
172
|
+
res = await fetchFn(`${baseUrl}/api/embeddings`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({ model: resolvedModel, prompt }),
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new OllamaUnavailableError(
|
|
179
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const raw = (await res.json().catch(() => ({}))) as OllamaEmbedResponse;
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
const msg = raw.error ?? `ollama embed HTTP ${res.status}`;
|
|
185
|
+
throw new OllamaUnavailableError(`ollama: ${msg}`);
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(raw.embedding)) {
|
|
188
|
+
throw new OllamaUnavailableError("ollama: embed response missing embedding");
|
|
189
|
+
}
|
|
190
|
+
vectors.push(raw.embedding);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
vectors,
|
|
195
|
+
model: resolvedModel,
|
|
196
|
+
driverId: "ollama",
|
|
197
|
+
};
|
|
198
|
+
},
|
|
161
199
|
};
|
|
162
200
|
}
|
|
163
201
|
|
|
@@ -325,3 +363,8 @@ interface OllamaChatResponse {
|
|
|
325
363
|
readonly eval_count?: number;
|
|
326
364
|
readonly error?: string;
|
|
327
365
|
}
|
|
366
|
+
|
|
367
|
+
interface OllamaEmbedResponse {
|
|
368
|
+
readonly embedding?: readonly number[];
|
|
369
|
+
readonly error?: string;
|
|
370
|
+
}
|
|
@@ -318,6 +318,37 @@ describe("ollama driver", () => {
|
|
|
318
318
|
expect(calls[0]!.body.options).toEqual({ temperature: 0, num_predict: 16 });
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
test("embed via injectable fetch — native /api/embeddings", async () => {
|
|
322
|
+
const paths: string[] = [];
|
|
323
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
324
|
+
async (input: string | URL | Request) => {
|
|
325
|
+
const url = String(input);
|
|
326
|
+
paths.push(url);
|
|
327
|
+
if (url.endsWith("/api/tags")) {
|
|
328
|
+
return new Response(JSON.stringify({ models: [] }), { status: 200 });
|
|
329
|
+
}
|
|
330
|
+
if (url.endsWith("/api/embeddings")) {
|
|
331
|
+
return new Response(JSON.stringify({ embedding: [0.1, 0.2, 0.3] }), {
|
|
332
|
+
status: 200,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return new Response(JSON.stringify({ error: "unexpected" }), { status: 500 });
|
|
336
|
+
},
|
|
337
|
+
{ preconnect: () => {} },
|
|
338
|
+
) as typeof fetch;
|
|
339
|
+
|
|
340
|
+
const client = await openOllama({
|
|
341
|
+
model: "nomic-embed-text",
|
|
342
|
+
baseUrl: "http://ollama.test:11434",
|
|
343
|
+
fetch: fetchFn,
|
|
344
|
+
});
|
|
345
|
+
const emb = await client.embed!({ input: ["a", "b"] });
|
|
346
|
+
expect(emb.driverId).toBe("ollama");
|
|
347
|
+
expect(emb.vectors).toHaveLength(2);
|
|
348
|
+
expect(emb.vectors[0]).toEqual([0.1, 0.2, 0.3]);
|
|
349
|
+
expect(paths.filter((p) => p.endsWith("/api/embeddings"))).toHaveLength(2);
|
|
350
|
+
});
|
|
351
|
+
|
|
321
352
|
test("unreachable server throws OllamaUnavailableError (no silent fallback)", async () => {
|
|
322
353
|
const fetchFn: typeof fetch = Object.assign(
|
|
323
354
|
async () => {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `fcm` channel driver — Firebase Cloud Messaging HTTP v1
|
|
2
|
+
* `fcm` channel driver — Firebase Cloud Messaging HTTP v1 via sently.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { FcmTransport } from "sently/transports/fcm";
|
|
6
|
+
import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
|
|
5
7
|
import type {
|
|
6
8
|
ChannelDriver,
|
|
7
9
|
ChannelMessage,
|
|
@@ -13,70 +15,64 @@ import type {
|
|
|
13
15
|
/**
|
|
14
16
|
* Open an FCM push driver.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
18
|
+
* Prefer service-account credentials (`clientEmail` + `privateKey` + `from`/
|
|
19
|
+
* `projectId`). For tests, pass `token` as a pre-fetched access token via
|
|
20
|
+
* `getAccessToken` (still requires `from` / project id).
|
|
21
|
+
*
|
|
22
|
+
* @param options - Project id + service account or injectable token
|
|
17
23
|
*/
|
|
18
24
|
export function openFcmChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
25
|
+
const projectId = options.projectId ?? options.from;
|
|
26
|
+
if (!projectId) {
|
|
27
|
+
throw new Error("fcm channel: projectId (or from) is required");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const clientEmail = options.clientEmail ?? options.user;
|
|
31
|
+
const privateKey = options.privateKey ?? options.pass;
|
|
19
32
|
const accessToken = options.token ?? options.apiKey;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
|
|
34
|
+
if (!accessToken && !(clientEmail && privateKey)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"fcm channel: clientEmail+privateKey (service account) or token (access token) required",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if ((clientEmail && !privateKey) || (!clientEmail && privateKey)) {
|
|
40
|
+
throw new Error("fcm channel: clientEmail and privateKey must be provided together");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Token-only mode (tests / pre-fetched OAuth): sently still requires placeholder
|
|
44
|
+
// service-account fields; getAccessToken skips JWT exchange.
|
|
45
|
+
const transport = new FcmTransport({
|
|
46
|
+
projectId,
|
|
47
|
+
clientEmail: clientEmail ?? "oke-fcm@local",
|
|
48
|
+
privateKey:
|
|
49
|
+
privateKey ??
|
|
50
|
+
"-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF6PZGFw=\n-----END PRIVATE KEY-----\n",
|
|
51
|
+
...(accessToken ? { getAccessToken: async () => accessToken } : {}),
|
|
52
|
+
});
|
|
23
53
|
|
|
24
54
|
const channel: ChannelTransport = {
|
|
25
55
|
provider: "fcm",
|
|
26
56
|
mediums: ["push"],
|
|
27
57
|
async send(message: ChannelMessage): Promise<ChannelSendResult> {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
notification: {
|
|
41
|
-
title: message.subject ?? message.template ?? "notification",
|
|
42
|
-
body: message.text ?? "",
|
|
43
|
-
},
|
|
44
|
-
data: Object.fromEntries(
|
|
45
|
-
Object.entries(message.data ?? {}).map(([k, v]) => [k, String(v)]),
|
|
46
|
-
),
|
|
47
|
-
},
|
|
48
|
-
}),
|
|
49
|
-
});
|
|
50
|
-
const body = (await res.json().catch(() => ({}))) as {
|
|
51
|
-
name?: string;
|
|
52
|
-
error?: { message?: string };
|
|
53
|
-
};
|
|
54
|
-
const id = body.name ?? crypto.randomUUID();
|
|
55
|
-
if (!res.ok) {
|
|
56
|
-
return {
|
|
57
|
-
ok: false,
|
|
58
|
-
messageId: id,
|
|
59
|
-
driverId: "fcm",
|
|
60
|
-
attempts: [
|
|
61
|
-
{
|
|
62
|
-
driverId: "fcm",
|
|
63
|
-
ok: false,
|
|
64
|
-
error: body.error?.message ?? `HTTP ${res.status}`,
|
|
65
|
-
at: Date.now(),
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
};
|
|
58
|
+
try {
|
|
59
|
+
const title = message.subject ?? message.template ?? "notification";
|
|
60
|
+
const body = message.text ?? "";
|
|
61
|
+
const result = await transport.send({
|
|
62
|
+
token: message.to,
|
|
63
|
+
title,
|
|
64
|
+
body,
|
|
65
|
+
...(message.data ? { data: { ...message.data } } : {}),
|
|
66
|
+
});
|
|
67
|
+
return mapSentlySendResult("fcm", result);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return mapSentlySendError("fcm", err);
|
|
69
70
|
}
|
|
70
|
-
return {
|
|
71
|
-
ok: true,
|
|
72
|
-
messageId: id,
|
|
73
|
-
driverId: "fcm",
|
|
74
|
-
attempts: [{ driverId: "fcm", ok: true, at: Date.now(), messageId: id }],
|
|
75
|
-
};
|
|
76
71
|
},
|
|
72
|
+
verify: () => transport.verify(),
|
|
77
73
|
};
|
|
78
74
|
|
|
79
|
-
return { id: "fcm", channel };
|
|
75
|
+
return { id: "fcm", channel, pushTransport: transport };
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
/** FCM driver factory. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `msegat` channel driver — SMS via sently's Msegat transport.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { MsegatTransport } from "sently/transports/msegat";
|
|
6
|
+
import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
|
|
7
|
+
import type {
|
|
8
|
+
ChannelDriver,
|
|
9
|
+
ChannelMessage,
|
|
10
|
+
ChannelOpenOptions,
|
|
11
|
+
ChannelSendResult,
|
|
12
|
+
ChannelTransport,
|
|
13
|
+
} from "./channel-types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Open a Msegat SMS driver.
|
|
17
|
+
*
|
|
18
|
+
* @param options - `userName`/`user` + `apiKey` + `sender`/`from`
|
|
19
|
+
*/
|
|
20
|
+
export function openMsegatChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
21
|
+
const userName = options.userName ?? options.user;
|
|
22
|
+
const apiKey = options.apiKey;
|
|
23
|
+
const sender = options.sender ?? options.from;
|
|
24
|
+
if (!userName) {
|
|
25
|
+
throw new Error("msegat channel: userName (or user) is required");
|
|
26
|
+
}
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
throw new Error("msegat channel: apiKey is required");
|
|
29
|
+
}
|
|
30
|
+
if (!sender) {
|
|
31
|
+
throw new Error("msegat channel: sender (or from) is required");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const transport = new MsegatTransport({ userName, apiKey, sender });
|
|
35
|
+
|
|
36
|
+
const channel: ChannelTransport = {
|
|
37
|
+
provider: "msegat",
|
|
38
|
+
mediums: ["sms"],
|
|
39
|
+
async send(message: ChannelMessage): Promise<ChannelSendResult> {
|
|
40
|
+
try {
|
|
41
|
+
const result = await transport.send({
|
|
42
|
+
to: message.to,
|
|
43
|
+
body: message.text ?? String(message.data?.code ?? ""),
|
|
44
|
+
...(message.from ? { from: message.from } : {}),
|
|
45
|
+
});
|
|
46
|
+
return mapSentlySendResult("msegat", result);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return mapSentlySendError("msegat", err);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
verify: () => transport.verify(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return { id: "msegat", channel, smsTransport: transport };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Msegat driver factory. */
|
|
58
|
+
export const msegatChannelDriver = {
|
|
59
|
+
id: "msegat" as const,
|
|
60
|
+
open: openMsegatChannel,
|
|
61
|
+
};
|