Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
410d05e0b1 | ||
|
|
19fac97d18 | ||
|
|
ea62dd11f0 | ||
|
|
4161863587 | ||
|
|
86becd50bc | ||
|
|
fefc295d15 | ||
|
|
e6c278198b | ||
|
|
3a9e57f682 | ||
|
|
b0fe619ae8 | ||
|
|
c13b9ea826 | ||
|
|
feec69adca | ||
|
|
d542b4e9ad | ||
|
|
cdb93ac1bc | ||
|
|
2e8e8de976 | ||
|
|
e75ab28041 | ||
|
|
4261535741 | ||
|
|
0166f31cfc | ||
|
|
caa9ad87e4 | ||
|
|
30a4df183c | ||
|
|
ee687e1d31 | ||
|
|
024d847b9a | ||
|
|
06586a6dc8 | ||
|
|
55fc5ee97b | ||
|
|
d9aea8e9c8 | ||
|
|
0048324d8a | ||
|
|
f7ef218507 |
@@ -1,5 +1,12 @@
|
|||||||
name: Release image
|
name: Release image
|
||||||
|
|
||||||
|
# Builds and pushes the multi-arch image to GHCR, the registry the installer
|
||||||
|
# defaults to.
|
||||||
|
#
|
||||||
|
# The Gitea registry is on a VPN, which a GitHub runner can never reach, so CI
|
||||||
|
# does not touch it at all: `scripts/mirror-gitea.sh` copies a release into it
|
||||||
|
# from the VPN side, in parallel and after the fact. Keeping the two apart is
|
||||||
|
# what makes a registry outage unable to fail a release.
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
@@ -30,13 +37,6 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to Gitea
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.tben.me
|
|
||||||
username: ${{ secrets.GITEA_USERNAME }}
|
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
|
|
||||||
# The version the app reports on Settings > General when the build
|
# The version the app reports on Settings > General when the build
|
||||||
# isn't a published release.
|
# isn't a published release.
|
||||||
- name: Read the source version
|
- name: Read the source version
|
||||||
@@ -45,6 +45,11 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT"
|
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# The same four tags for every kind of build: a release gets its own
|
||||||
|
# version plus the moving `latest` and `major.minor`, so that
|
||||||
|
# scripts/mirror-gitea.sh can copy whichever of them still belong to
|
||||||
|
# it, and a dispatch build gets the commit tag only, since it is not a
|
||||||
|
# release and must not move `latest`.
|
||||||
- name: Work out image tags
|
- name: Work out image tags
|
||||||
id: tags
|
id: tags
|
||||||
env:
|
env:
|
||||||
@@ -57,32 +62,24 @@ jobs:
|
|||||||
if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then
|
if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then
|
||||||
version="$RELEASE_TAG"
|
version="$RELEASE_TAG"
|
||||||
minor="$(printf '%s' "$version" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/\1.\2/')"
|
minor="$(printf '%s' "$version" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/\1.\2/')"
|
||||||
ghcr="$(printf '%s\n' \
|
tags="$(printf '%s\n' \
|
||||||
"ghcr.io/moduletrace/cobble:latest" \
|
"ghcr.io/moduletrace/cobble:latest" \
|
||||||
"ghcr.io/moduletrace/cobble:${version}" \
|
"ghcr.io/moduletrace/cobble:${version}" \
|
||||||
"ghcr.io/moduletrace/cobble:${minor}" \
|
"ghcr.io/moduletrace/cobble:${minor}" \
|
||||||
"ghcr.io/moduletrace/cobble:${sha}")"
|
"ghcr.io/moduletrace/cobble:${sha}")"
|
||||||
gitea="$(printf '%s\n' \
|
|
||||||
"git.tben.me/moduletrace/cobble:latest" \
|
|
||||||
"git.tben.me/moduletrace/cobble:${version}" \
|
|
||||||
"git.tben.me/moduletrace/cobble:${minor}" \
|
|
||||||
"git.tben.me/moduletrace/cobble:${sha}")"
|
|
||||||
else
|
else
|
||||||
version="$SOURCE_VERSION"
|
version="$SOURCE_VERSION"
|
||||||
ghcr="ghcr.io/moduletrace/cobble:${sha}"
|
tags="ghcr.io/moduletrace/cobble:${sha}"
|
||||||
gitea="git.tben.me/moduletrace/cobble:${sha}"
|
|
||||||
fi
|
fi
|
||||||
{
|
{
|
||||||
echo "version=$version"
|
echo "version=$version"
|
||||||
echo "ghcr<<EOF"
|
echo "tags<<EOF"
|
||||||
echo "$ghcr"
|
echo "$tags"
|
||||||
echo "EOF"
|
|
||||||
echo "gitea<<EOF"
|
|
||||||
echo "$gitea"
|
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# The registry the installer defaults to, so this one must succeed.
|
# The only push CI makes. The installer defaults to this registry, so
|
||||||
|
# this step failing has to fail the release.
|
||||||
- name: Build and push to GHCR
|
- name: Build and push to GHCR
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -92,62 +89,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COBBLE_VERSION=${{ steps.tags.outputs.version }}
|
COBBLE_VERSION=${{ steps.tags.outputs.version }}
|
||||||
tags: ${{ steps.tags.outputs.ghcr }}
|
tags: ${{ steps.tags.outputs.tags }}
|
||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
# The mirror's registry, from the image just built: no second build,
|
|
||||||
# and Cloudflare rate limiting git.tben.me (429/1015 on blob uploads)
|
|
||||||
# can never fail a release the installer depends on. It must still be
|
|
||||||
# loud: `continue-on-error` alone leaves the check green when the
|
|
||||||
# mirror does nothing at all, which is how the 413 below went unseen
|
|
||||||
# for four releases.
|
|
||||||
- name: Mirror to Gitea
|
|
||||||
id: mirror
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
GITEA_TAGS: ${{ steps.tags.outputs.gitea }}
|
|
||||||
GHCR_IMAGES: ${{ steps.tags.outputs.ghcr }}
|
|
||||||
run: |
|
|
||||||
set -uo pipefail
|
|
||||||
first_ghcr="$(printf '%s\n' "$GHCR_IMAGES" | head -n1)"
|
|
||||||
args=()
|
|
||||||
while IFS= read -r tag; do
|
|
||||||
[ -n "$tag" ] || continue
|
|
||||||
args+=(--tag "${tag}")
|
|
||||||
done <<< "$GITEA_TAGS"
|
|
||||||
|
|
||||||
output="$(mktemp)"
|
|
||||||
status=0
|
|
||||||
docker buildx imagetools create "${args[@]}" "${first_ghcr}" 2>&1 | tee "$output" || status=$?
|
|
||||||
|
|
||||||
if [ "$status" -eq 0 ]; then
|
|
||||||
{
|
|
||||||
echo "### Gitea mirror"
|
|
||||||
echo
|
|
||||||
echo "Mirrored \`${first_ghcr}\` to:"
|
|
||||||
echo
|
|
||||||
printf -- '- \`%s\`\n' $GITEA_TAGS
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The usual cause: git.tben.me sits behind Cloudflare, whose
|
|
||||||
# request-body cap is 100 MB, and this image has a 243 MiB
|
|
||||||
# `bun install` layer that no route through it can carry.
|
|
||||||
reason="$(grep -oE '[0-9]{3} [A-Za-z ]{0,40}' "$output" | tail -n1)"
|
|
||||||
{
|
|
||||||
echo "### Gitea mirror failed"
|
|
||||||
echo
|
|
||||||
echo "\`${first_ghcr}\` did **not** reach the Gitea registry. The release itself is fine: the installer defaults to GHCR."
|
|
||||||
echo
|
|
||||||
echo '```'
|
|
||||||
tail -n 12 "$output"
|
|
||||||
echo '```'
|
|
||||||
echo
|
|
||||||
echo "See the [registry push limits](https://developers.cloudflare.com/workers/platform/limits/): git.tben.me is behind Cloudflare, which refuses any request body over 100 MB, and this image's \`bun install\` layer alone is about 243 MiB."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "::warning title=Gitea mirror failed::${reason:-docker buildx imagetools create failed}; the image did not reach git.tben.me. See the job summary."
|
|
||||||
exit "$status"
|
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ point that at `http://<this machine>:7227`:
|
|||||||
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
|
sign-in cookies `Secure`; Caddy does this by default, and nginx needs
|
||||||
`proxy_set_header X-Forwarded-Proto $scheme;`.
|
`proxy_set_header X-Forwarded-Proto $scheme;`.
|
||||||
- Pass `/ws` through as well as ordinary requests, including the upgrade.
|
- Pass `/ws` through as well as ordinary requests, including the upgrade.
|
||||||
|
- Keep the address the browser used in `Host` (or send it as
|
||||||
|
`X-Forwarded-Host`). Live updates and the computers' terminals only accept
|
||||||
|
a websocket from the app's own address; Caddy and Cloudflare Tunnel keep
|
||||||
|
it, and nginx needs `proxy_set_header Host $host;`.
|
||||||
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
|
- Give the proxy a real certificate. Browsers treat plain HTTP as unsafe, so
|
||||||
the installable app, offline support, Web Push notifications and voice
|
the installable app, offline support, Web Push notifications and voice
|
||||||
recording stay off until the address is HTTPS.
|
recording stay off until the address is HTTPS.
|
||||||
@@ -245,15 +249,16 @@ queue and Web Push.
|
|||||||
|
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
Every release publishes a multi-arch image to both registries:
|
Every release publishes a multi-arch image to
|
||||||
|
`ghcr.io/moduletrace/cobble`, which is what the installer pulls. Tags are
|
||||||
|
`latest`, the release version, `major.minor` and the commit sha.
|
||||||
|
|
||||||
- `ghcr.io/moduletrace/cobble`
|
The same image is copied into `git.tben.me/moduletrace/cobble` by
|
||||||
- `git.tben.me/moduletrace/cobble`
|
`bun run mirror:gitea`, which runs after the build over the VPN (the Gitea
|
||||||
|
registry is not on the public internet, so GitHub Actions cannot reach it).
|
||||||
Tags are `latest`, the release version, `major.minor` and the commit sha.
|
Install from Gitea with `--image git.tben.me/moduletrace/cobble`, or build
|
||||||
Install from Gitea with
|
from source with `docker build -t cobble:local .` and
|
||||||
`--image git.tben.me/moduletrace/cobble`, or build from source with
|
`--image cobble --version local --no-pull`.
|
||||||
`docker build -t cobble:local .` and `--image cobble --version local --no-pull`.
|
|
||||||
|
|
||||||
A release is published from a clean `main` with the two scripts, which tag the
|
A release is published from a clean `main` with the two scripts, which tag the
|
||||||
`package.json` version, push and create the release objects:
|
`package.json` version, push and create the release objects:
|
||||||
@@ -264,7 +269,9 @@ scripts/publish-gitea.sh # or: bun run publish:gitea
|
|||||||
```
|
```
|
||||||
|
|
||||||
Both take `--version VERSION`, `--dry-run` and `--yes`. Only the GitHub release
|
Both take `--version VERSION`, `--dry-run` and `--yes`. Only the GitHub release
|
||||||
builds the image; see [RELEASING.md](RELEASING.md) for the full checklist.
|
builds the image, and the Gitea registry is filled in afterwards, once that
|
||||||
|
build has finished and from the VPN, by `bun run mirror:gitea`; see
|
||||||
|
[RELEASING.md](RELEASING.md) for the full checklist.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -62,16 +62,24 @@ them in that order, since only the GitHub release builds images.
|
|||||||
gh release create v0.1.0 --title v0.1.0 --notes-file notes.md --latest
|
gh release create v0.1.0 --title v0.1.0 --notes-file notes.md --latest
|
||||||
|
|
||||||
7. [ ] The release workflow builds `linux/amd64` and `linux/arm64` and
|
7. [ ] The release workflow builds `linux/amd64` and `linux/arm64` and
|
||||||
pushes to both registries:
|
pushes to `ghcr.io/moduletrace/cobble`, the registry the installer
|
||||||
- `ghcr.io/moduletrace/cobble`
|
defaults to. It does not touch Gitea: that registry is on the VPN,
|
||||||
- `git.tben.me/moduletrace/cobble`
|
and a GitHub runner cannot reach it.
|
||||||
8. [ ] Check the manifests:
|
8. [ ] Check the manifests:
|
||||||
|
|
||||||
docker buildx imagetools inspect ghcr.io/moduletrace/cobble:latest
|
docker buildx imagetools inspect ghcr.io/moduletrace/cobble:latest
|
||||||
docker buildx imagetools inspect git.tben.me/moduletrace/cobble:latest
|
|
||||||
|
|
||||||
9. [ ] Confirm the GHCR package is public and pull it anonymously.
|
9. [ ] Confirm the GHCR package is public and pull it anonymously.
|
||||||
10. [ ] Publish the release to Gitea as well:
|
10. [ ] Once the build has finished, copy the image into the Gitea registry
|
||||||
|
from the VPN:
|
||||||
|
|
||||||
|
GITEA_TOKEN=... bun run mirror:gitea # or: scripts/mirror-gitea.sh
|
||||||
|
|
||||||
|
It copies the version and commit tags, plus `latest` and
|
||||||
|
`major.minor` while this release still owns them, and reads every
|
||||||
|
destination digest back. A failure here does not affect the release:
|
||||||
|
the installer keeps working from GHCR.
|
||||||
|
11. [ ] Publish the release to Gitea as well:
|
||||||
|
|
||||||
scripts/publish-gitea.sh # or: bun run publish:gitea
|
scripts/publish-gitea.sh # or: bun run publish:gitea
|
||||||
|
|
||||||
@@ -79,7 +87,7 @@ them in that order, since only the GitHub release builds images.
|
|||||||
git push gitea main
|
git push gitea main
|
||||||
git push gitea refs/tags/v0.1.0
|
git push gitea refs/tags/v0.1.0
|
||||||
|
|
||||||
11. [ ] Update every install with the one-liner:
|
12. [ ] Update every install with the one-liner:
|
||||||
|
|
||||||
curl -fsSL https://url.moduletrace.com/cobble | sudo bash
|
curl -fsSL https://url.moduletrace.com/cobble | sudo bash
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||||
"@moduletrace/bunext": "^1.3.3",
|
"@moduletrace/bunext": "^1.3.3",
|
||||||
"@moduletrace/twui": "^0.1.6",
|
"@moduletrace/twui": "^0.1.7",
|
||||||
"@tiptap/extension-bubble-menu": "3.32.0",
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
||||||
"@tiptap/extension-mention": "3.32.0",
|
"@tiptap/extension-mention": "3.32.0",
|
||||||
"@tiptap/extension-placeholder": "3.32.0",
|
"@tiptap/extension-placeholder": "3.32.0",
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
|
|
||||||
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
|
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
|
||||||
|
|
||||||
"@moduletrace/twui": ["@moduletrace/[email protected].6", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.6/twui-0.1.6.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-BFNIV1itkIYXmhq1rBJA8E8MjwN9AP7MESbOnzM0yUb6xbsOFIBbmlKfUu6kTr7QCl1TWNxWTGFPVmQ2S54cDA=="],
|
"@moduletrace/twui": ["@moduletrace/[email protected].7", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.7/twui-0.1.7.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-/Y9QyhtQsoEAJO+ahlGYQaYMx3gUWS+DlSkaBVjAIY5V9OQ6i7qhhqMNcmnfOi9m3eyAze1uW00jox+RDUqLpA=="],
|
||||||
|
|
||||||
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
|
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
|
||||||
|
|
||||||
|
|||||||
+293
-1
@@ -1,4 +1,5 @@
|
|||||||
import { LogCategories } from "@/src/dict/log-categories";
|
import { LogCategories } from "@/src/dict/log-categories";
|
||||||
|
import { BotTypes } from "@/src/dict/bot-types-dict";
|
||||||
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
||||||
import {
|
import {
|
||||||
BotFaces,
|
BotFaces,
|
||||||
@@ -230,7 +231,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "kind",
|
fieldName: "kind",
|
||||||
fieldDescription: `One coordinator per owner; everything else is a worker`,
|
fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``,
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
options: BotKinds.map((bk) => bk.value),
|
options: BotKinds.map((bk) => bk.value),
|
||||||
defaultValue: "worker",
|
defaultValue: "worker",
|
||||||
@@ -439,6 +440,13 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
options: [0, 1],
|
options: [0, 1],
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "interim",
|
||||||
|
fieldDescription: `Sent mid-reply with send_message (an acknowledgement, a heads-up): the bot is still working on the reply, so the thinking row stays up while its face is working`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "reply_to_message_id",
|
fieldName: "reply_to_message_id",
|
||||||
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
|
fieldDescription: `The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote`,
|
||||||
@@ -1224,6 +1232,60 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_types",
|
||||||
|
tableDescription: `A bot's types, like \`user_types\` for users: what it can do (\`BotTypePermissionMatrix\`). A row with no workspace holds everywhere (the coordinator); one with a workspace only there (a workspace admin). A bot with no row is a worker. One per bot, type and workspace, enforced in \`setBotType\` since SQLite treats NULL ids as distinct`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_types_bot_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_type",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotTypes.map((bt) => bt.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The workspace the type holds in; null for install-wide`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_types_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "granted_by_user_id",
|
||||||
|
fieldDescription: `The user who granted it (or for whom the granting bot ran). Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "granted_by_bot_id",
|
||||||
|
fieldDescription: `The bot that granted it from chat (the coordinator), if any. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_types_bot_id_workspace_id",
|
||||||
|
indexTableFields: ["bot_id", "workspace_id"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_types_workspace_id",
|
||||||
|
indexTableFields: ["workspace_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "workspace_users",
|
tableName: "workspace_users",
|
||||||
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
|
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
|
||||||
@@ -2209,6 +2271,88 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "extension_proposals",
|
||||||
|
tableDescription: `An MCP server, custom tool or skill the coordinator proposed adding or changing, waiting for the super admin to confirm or reject it from the chat. Nothing is saved, connected or run until they confirm: the card shows where it sends, which envs it reads (saved or missing), for a skill what its runbook tells bots to do and what changed, and the bot's background check`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "kind",
|
||||||
|
fieldDescription: `mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: ["mcp", "tool", "skill"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "extension_id",
|
||||||
|
fieldDescription: `The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "name",
|
||||||
|
fieldDescription: `The server's or tool's name, for the chip`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "data",
|
||||||
|
fieldDescription: `JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "research",
|
||||||
|
fieldDescription: `The bot's background check, markdown: who publishes it, whether it's official, what it needs`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "sources",
|
||||||
|
fieldDescription: `JSON array of the URLs the background check read`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "status",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: ["pending", "confirmed", "rejected", "replaced", "failed"],
|
||||||
|
defaultValue: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "outcome",
|
||||||
|
fieldDescription: `What happened on confirm (saved, connected, keys missing) or why it failed`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "result_id",
|
||||||
|
fieldDescription: `The server or tool it saved, once confirmed`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "expires_at",
|
||||||
|
fieldDescription: `A pending proposal can't be confirmed after this (Unix ms)`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "decided_at",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `The super admin whose conversation it was proposed in`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot that proposed it (the coordinator). Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The conversation it was proposed in, where the outcome is reported. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace, null for the bot's own chat. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "bot_mcp_servers",
|
tableName: "bot_mcp_servers",
|
||||||
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
||||||
@@ -2866,6 +3010,154 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "token_usages",
|
||||||
|
tableDescription: `One model call that reported its usage: the tokens it spent, on which model and provider, for whom and for what. A bot message's total is the sum of the calls it took; the usage page sums them by model, provider and period`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `Whose work it was: the user whose conversation the call ran for`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "users",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_user_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot it was for, when it was for one`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_bot_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The workspace the call was in; null for a bot's own chat`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The conversation it was in`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_sessions",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_session_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "message_id",
|
||||||
|
fieldDescription: `The message it produced, so the thread can show what an answer cost. Null for calls that produce no message (naming a session, embedding a file)`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_messages",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_message_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "job_id",
|
||||||
|
fieldDescription: `The queued job it ran for, when it ran for one`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots_queue",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "token_usage_job_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "harness",
|
||||||
|
fieldDescription: `What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: Harnesses.map((h) => h.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "provider_id",
|
||||||
|
fieldDescription: `The provider that served it. Not a foreign key: it stays readable after the provider is deleted`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "provider_name",
|
||||||
|
fieldDescription: `The provider's name as it was, so a deleted provider still reads`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "model_id",
|
||||||
|
fieldDescription: `The catalog row of the model. Not a foreign key: a model the provider stops listing still counts`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "model_slug",
|
||||||
|
fieldDescription: `The provider's model id, as it was called`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "prompt_tokens",
|
||||||
|
fieldDescription: `Tokens sent: the prompt, the system instructions and the tool results`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "completion_tokens",
|
||||||
|
fieldDescription: `Tokens written back: the answer, its tool calls and its reasoning`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "cost",
|
||||||
|
fieldDescription: `The provider's price in USD, when it reports one`,
|
||||||
|
dataType: "REAL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "settings",
|
||||||
|
fieldDescription: `JSON (\`HarnessCallSettings\`): how the call was made. The generation settings sent (temperature, reasoning effort, max tokens, voice), where they came from, whether the model was picked or automatic, and the models that failed before it`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_token_usages_created_at",
|
||||||
|
indexTableFields: ["created_at"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_token_usages_message_id",
|
||||||
|
indexTableFields: ["message_id"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_token_usages_provider_model",
|
||||||
|
indexTableFields: ["provider_id", "model_slug"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_token_usages_user_created_at",
|
||||||
|
indexTableFields: ["user_id", "created_at"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_token_usages_bot_created_at",
|
||||||
|
indexTableFields: ["bot_id", "created_at"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "bot_file_vectors",
|
tableName: "bot_file_vectors",
|
||||||
tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`,
|
tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`,
|
||||||
|
|||||||
+182
-2
@@ -19,6 +19,7 @@ export const BunSQLiteTables = [
|
|||||||
"cron_jobs",
|
"cron_jobs",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
"workspace_bots",
|
"workspace_bots",
|
||||||
|
"bot_types",
|
||||||
"workspace_users",
|
"workspace_users",
|
||||||
"bot_users",
|
"bot_users",
|
||||||
"bot_user_memories",
|
"bot_user_memories",
|
||||||
@@ -36,6 +37,7 @@ export const BunSQLiteTables = [
|
|||||||
"mcp_servers",
|
"mcp_servers",
|
||||||
"custom_tools",
|
"custom_tools",
|
||||||
"skills",
|
"skills",
|
||||||
|
"extension_proposals",
|
||||||
"bot_mcp_servers",
|
"bot_mcp_servers",
|
||||||
"workspace_mcp_servers",
|
"workspace_mcp_servers",
|
||||||
"bot_custom_tools",
|
"bot_custom_tools",
|
||||||
@@ -48,6 +50,7 @@ export const BunSQLiteTables = [
|
|||||||
"envs",
|
"envs",
|
||||||
"bot_files",
|
"bot_files",
|
||||||
"bot_file_chunks",
|
"bot_file_chunks",
|
||||||
|
"token_usages",
|
||||||
"bot_file_vectors",
|
"bot_file_vectors",
|
||||||
"file_ingest_queue",
|
"file_ingest_queue",
|
||||||
"app_settings",
|
"app_settings",
|
||||||
@@ -191,7 +194,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
|
|||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
name?: string;
|
name?: string;
|
||||||
/**
|
/**
|
||||||
* One coordinator per owner; everything else is a worker
|
* Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types`
|
||||||
*/
|
*/
|
||||||
kind?: "worker" | "coordinator" | "";
|
kind?: "worker" | "coordinator" | "";
|
||||||
/**
|
/**
|
||||||
@@ -308,6 +311,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
|||||||
* Renders the Needs you chip under the message
|
* Renders the Needs you chip under the message
|
||||||
*/
|
*/
|
||||||
needs_you?: 0 | 1 | "";
|
needs_you?: 0 | 1 | "";
|
||||||
|
/**
|
||||||
|
* Sent mid-reply with send_message (an acknowledgement, a heads-up): the bot is still working on the reply, so the thinking row stays up while its face is working
|
||||||
|
*/
|
||||||
|
interim?: 0 | 1 | "";
|
||||||
/**
|
/**
|
||||||
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
|
* The message this one replies to, quoted above it. Not a foreign key, so deleting the quoted message only drops the quote
|
||||||
*/
|
*/
|
||||||
@@ -761,6 +768,35 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
|
|||||||
memory?: string;
|
memory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_TYPES = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
bot_id?: number | "";
|
||||||
|
bot_type?: "coordinator" | "admin" | "worker" | "";
|
||||||
|
/**
|
||||||
|
* The workspace the type holds in; null for install-wide
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The user who granted it (or for whom the granting bot ran). Not a foreign key
|
||||||
|
*/
|
||||||
|
granted_by_user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that granted it from chat (the coordinator), if any. Not a foreign key
|
||||||
|
*/
|
||||||
|
granted_by_bot_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
|
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1358,6 +1394,75 @@ export type BUN_SQLITE_COBBLE_SKILLS = {
|
|||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)
|
||||||
|
*/
|
||||||
|
kind?: "mcp" | "tool" | "skill" | "";
|
||||||
|
/**
|
||||||
|
* The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm
|
||||||
|
*/
|
||||||
|
extension_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The server's or tool's name, for the chip
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)
|
||||||
|
*/
|
||||||
|
data?: string;
|
||||||
|
/**
|
||||||
|
* The bot's background check, markdown: who publishes it, whether it's official, what it needs
|
||||||
|
*/
|
||||||
|
research?: string;
|
||||||
|
/**
|
||||||
|
* JSON array of the URLs the background check read
|
||||||
|
*/
|
||||||
|
sources?: string;
|
||||||
|
status?: "pending" | "confirmed" | "rejected" | "replaced" | "failed" | "";
|
||||||
|
/**
|
||||||
|
* What happened on confirm (saved, connected, keys missing) or why it failed
|
||||||
|
*/
|
||||||
|
outcome?: string;
|
||||||
|
/**
|
||||||
|
* The server or tool it saved, once confirmed
|
||||||
|
*/
|
||||||
|
result_id?: number | "";
|
||||||
|
/**
|
||||||
|
* A pending proposal can't be confirmed after this (Unix ms)
|
||||||
|
*/
|
||||||
|
expires_at?: number | "";
|
||||||
|
decided_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The super admin whose conversation it was proposed in
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that proposed it (the coordinator). Not a foreign key
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The conversation it was proposed in, where the outcome is reported. Not a foreign key
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace, null for the bot's own chat. Not a foreign key
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1717,6 +1822,81 @@ export type BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS = {
|
|||||||
token_estimate?: number | "";
|
token_estimate?: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* Whose work it was: the user whose conversation the call ran for
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot it was for, when it was for one
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The workspace the call was in; null for a bot's own chat
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The conversation it was in
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The message it produced, so the thread can show what an answer cost. Null for calls that produce no message (naming a session, embedding a file)
|
||||||
|
*/
|
||||||
|
message_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The queued job it ran for, when it ran for one
|
||||||
|
*/
|
||||||
|
job_id?: number | "";
|
||||||
|
/**
|
||||||
|
* What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding
|
||||||
|
*/
|
||||||
|
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
|
||||||
|
/**
|
||||||
|
* The provider that served it. Not a foreign key: it stays readable after the provider is deleted
|
||||||
|
*/
|
||||||
|
provider_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The provider's name as it was, so a deleted provider still reads
|
||||||
|
*/
|
||||||
|
provider_name?: string;
|
||||||
|
/**
|
||||||
|
* The catalog row of the model. Not a foreign key: a model the provider stops listing still counts
|
||||||
|
*/
|
||||||
|
model_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The provider's model id, as it was called
|
||||||
|
*/
|
||||||
|
model_slug?: string;
|
||||||
|
/**
|
||||||
|
* Tokens sent: the prompt, the system instructions and the tool results
|
||||||
|
*/
|
||||||
|
prompt_tokens?: number | "";
|
||||||
|
/**
|
||||||
|
* Tokens written back: the answer, its tool calls and its reasoning
|
||||||
|
*/
|
||||||
|
completion_tokens?: number | "";
|
||||||
|
/**
|
||||||
|
* The provider's price in USD, when it reports one
|
||||||
|
*/
|
||||||
|
cost?: number | "";
|
||||||
|
/**
|
||||||
|
* JSON (`HarnessCallSettings`): how the call was made. The generation settings sent (temperature, reasoning effort, max tokens, voice), where they came from, whether the model was picked or automatic, and the models that failed before it
|
||||||
|
*/
|
||||||
|
settings?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
|
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1800,4 +1980,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"check:bundle": "bun src/scripts/check-bundle-size.ts",
|
"check:bundle": "bun src/scripts/check-bundle-size.ts",
|
||||||
"publish:github": "bash scripts/publish-github.sh",
|
"publish:github": "bash scripts/publish-github.sh",
|
||||||
"publish:gitea": "bash scripts/publish-gitea.sh"
|
"publish:gitea": "bash scripts/publish-gitea.sh",
|
||||||
|
"mirror:gitea": "bash scripts/mirror-gitea.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/mdx": "^16.3.0",
|
"@next/mdx": "^16.3.0",
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||||
"@moduletrace/bunext": "^1.3.3",
|
"@moduletrace/bunext": "^1.3.3",
|
||||||
"@moduletrace/twui": "^0.1.6",
|
"@moduletrace/twui": "^0.1.7",
|
||||||
"@tiptap/extension-bubble-menu": "3.32.0",
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
||||||
"@tiptap/extension-mention": "3.32.0",
|
"@tiptap/extension-mention": "3.32.0",
|
||||||
"@tiptap/extension-placeholder": "3.32.0",
|
"@tiptap/extension-placeholder": "3.32.0",
|
||||||
|
|||||||
Executable
+318
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copies a published Cobble image from GHCR into the Gitea registry.
|
||||||
|
#
|
||||||
|
# scripts/mirror-gitea.sh
|
||||||
|
#
|
||||||
|
# The Gitea registry is on the VPN and GitHub Actions runners are on the
|
||||||
|
# public internet, so CI can never reach it. The release workflow therefore
|
||||||
|
# builds and pushes to GHCR only, and this script mirrors the result
|
||||||
|
# separately: a registry that is unreachable, slow or full can never fail a
|
||||||
|
# release the installer depends on.
|
||||||
|
#
|
||||||
|
# It copies from the Gitea host itself, because that host reaches GHCR over
|
||||||
|
# the public internet and its own registry over the docker network it shares
|
||||||
|
# with it. Nothing has to be installed on that host: skopeo runs there in a
|
||||||
|
# container.
|
||||||
|
#
|
||||||
|
# `skopeo copy --all` keeps the manifest list and its signatures, so every
|
||||||
|
# architecture comes across and the digest is unchanged. Each destination tag
|
||||||
|
# is then read back and compared, so a partial copy is reported as a failure
|
||||||
|
# instead of being assumed to have worked.
|
||||||
|
#
|
||||||
|
# Run it after scripts/publish-github.sh: that release is what builds the
|
||||||
|
# image, so there is nothing to copy until it has finished.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/publish-common.sh
|
||||||
|
. "$SCRIPT_DIR/lib/publish-common.sh"
|
||||||
|
# shellcheck source=lib/publish-release.sh
|
||||||
|
. "$SCRIPT_DIR/lib/publish-release.sh"
|
||||||
|
|
||||||
|
SOURCE_REPO="${MIRROR_SOURCE_REPO:-moduletrace/cobble}"
|
||||||
|
SOURCE_REGISTRY_HOST="${MIRROR_SOURCE_REGISTRY_HOST:-ghcr.io}"
|
||||||
|
HOST="${MIRROR_HOST:-10.7.0.4}"
|
||||||
|
HOST_USER="${MIRROR_HOST_USER:-root}"
|
||||||
|
REGISTRY_HOST="${MIRROR_REGISTRY_HOST:-gitea:3000}"
|
||||||
|
REGISTRY_REPO="${MIRROR_REGISTRY_REPO:-moduletrace/cobble}"
|
||||||
|
# The name a person types, which is not the one the copy uses: the host
|
||||||
|
# reaches its registry by docker network name, while an install reaches it
|
||||||
|
# through the proxy in front of it.
|
||||||
|
REGISTRY_PUBLIC_HOST="${MIRROR_REGISTRY_PUBLIC_HOST:-git.tben.me}"
|
||||||
|
NETWORK="${MIRROR_NETWORK:-cloudflare}"
|
||||||
|
SKOPEO_IMAGE="${MIRROR_SKOPEO_IMAGE:-quay.io/skopeo/stable:latest}"
|
||||||
|
MIRROR_USER="${MIRROR_USER:-cobble}"
|
||||||
|
CONNECT_TIMEOUT="${MIRROR_CONNECT_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Copies a published Cobble image from GHCR into the Gitea registry.
|
||||||
|
|
||||||
|
Usage: scripts/mirror-gitea.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Version to mirror, with or without the leading v.
|
||||||
|
Defaults to package.json's version.
|
||||||
|
--yes, -y Do not prompt.
|
||||||
|
--dry-run Print what would run, copy nothing.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The version is copied as vX.Y.Z and sha-<commit>. It is copied as latest and
|
||||||
|
major.minor too, but only while it is still the newest release in GHCR, so
|
||||||
|
mirroring an older release never rolls those two tags backwards. Every
|
||||||
|
destination is read back and its digest compared with GHCR's.
|
||||||
|
|
||||||
|
Run it after scripts/publish-github.sh, and after the image build for that
|
||||||
|
release has finished.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_TOKEN Token for the registry (required).
|
||||||
|
MIRROR_HOST The Gitea host to copy from over the VPN.
|
||||||
|
Default: 10.7.0.4
|
||||||
|
MIRROR_HOST_USER Its ssh user. Default: root
|
||||||
|
MIRROR_REGISTRY_HOST The registry as that host sees it, by its docker
|
||||||
|
network name. Default: gitea:3000
|
||||||
|
MIRROR_REGISTRY_PUBLIC_HOST
|
||||||
|
The registry's public name, for the pull command
|
||||||
|
it prints. Default: git.tben.me
|
||||||
|
MIRROR_SOURCE_REGISTRY_HOST
|
||||||
|
Where the release workflow pushed.
|
||||||
|
Default: ghcr.io
|
||||||
|
MIRROR_NETWORK The docker network the registry is on, so the
|
||||||
|
copy container can resolve it.
|
||||||
|
Default: cloudflare
|
||||||
|
MIRROR_USER The registry user for basic auth. Default: cobble
|
||||||
|
MIRROR_SKOPEO_IMAGE The skopeo image the host runs.
|
||||||
|
Default: quay.io/skopeo/stable:latest
|
||||||
|
DRY_RUN=1, ASSUME_YES=1 Same as --dry-run and --yes.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_publish_args "$@"
|
||||||
|
if [ "$HELP" = "1" ]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
reject_unknown_args "$0"
|
||||||
|
|
||||||
|
# MARK:mirror-tags
|
||||||
|
# The moving tag a release owns: major.minor, read from the version the same
|
||||||
|
# way the release workflow reads it, so the two never disagree.
|
||||||
|
mirror_minor_tag() {
|
||||||
|
printf '%s' "${1#v}" | sed -E 's/^([0-9]+)\.([0-9]+).*/\1.\2/'
|
||||||
|
}
|
||||||
|
|
||||||
|
# The commit tag for a release: the first 12 characters of the commit the
|
||||||
|
# tag points at, which is `sha-${GITHUB_SHA::12}` in the workflow.
|
||||||
|
mirror_sha_tag() {
|
||||||
|
local sha
|
||||||
|
sha="$(git rev-parse -q --verify "refs/tags/$1^{commit}" 2>/dev/null || true)"
|
||||||
|
if [ -z "$sha" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'sha-%s' "${sha:0:12}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The tags this release owns outright, which no newer release claims.
|
||||||
|
mirror_fixed_tags() {
|
||||||
|
local sha_tag
|
||||||
|
printf '%s\n' "$1"
|
||||||
|
if sha_tag="$(mirror_sha_tag "$1")"; then
|
||||||
|
printf '%s\n' "$sha_tag"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The tags a newer release takes over.
|
||||||
|
mirror_moving_tags() {
|
||||||
|
printf 'latest\n%s\n' "$(mirror_minor_tag "$1")"
|
||||||
|
}
|
||||||
|
# END:mirror-tags
|
||||||
|
|
||||||
|
# MARK:mirror-guard
|
||||||
|
# Whether this release may still write the moving tags. It may while GHCR's
|
||||||
|
# `latest` is its own image: once a newer release has moved `latest`, copying
|
||||||
|
# an older version into the Gitea registry must not drag it back.
|
||||||
|
mirror_owns_moving_tags() {
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
# Nothing to compare against, so this release is taken as the newest.
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[ "$1" = "$2" ]
|
||||||
|
}
|
||||||
|
# END:mirror-guard
|
||||||
|
|
||||||
|
# MARK:mirror-digest
|
||||||
|
MANIFEST_ACCEPT="application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
|
||||||
|
# Reads a tag's digest from GHCR over the public internet. The token is
|
||||||
|
# anonymous: the image is public, and a registry answers a bare manifest
|
||||||
|
# request with 401 until it is given one.
|
||||||
|
github_registry_digest() {
|
||||||
|
local tag="$1"
|
||||||
|
local token
|
||||||
|
token="$(
|
||||||
|
curl -sSf "https://$SOURCE_REGISTRY_HOST/token?scope=repository:$SOURCE_REPO:pull" 2>/dev/null |
|
||||||
|
sed -n 's/.*"token":"\([^"]*\)".*/\1/p'
|
||||||
|
)" || return 1
|
||||||
|
if [ -z "$token" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
curl -sSf -o /dev/null -D- \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Accept: $MANIFEST_ACCEPT" \
|
||||||
|
"https://$SOURCE_REGISTRY_HOST/v2/$SOURCE_REPO/manifests/$tag" 2>/dev/null |
|
||||||
|
tr -d '\r' | sed -n 's/^docker-content-digest: //Ip' | head -n1
|
||||||
|
}
|
||||||
|
# END:mirror-digest
|
||||||
|
|
||||||
|
# MARK:mirror-remote
|
||||||
|
# Runs a bash script on the Gitea host, which is the one place that reaches
|
||||||
|
# both registries. The script arrives on stdin, so nothing is installed there
|
||||||
|
# and no quoting has to survive a round trip through ssh.
|
||||||
|
mirror_on_host() {
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout="$CONNECT_TIMEOUT" "$HOST_USER@$HOST" \
|
||||||
|
"MIRROR_NETWORK='$NETWORK' MIRROR_SKOPEO_IMAGE='$SKOPEO_IMAGE' bash -s"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads a destination tag's digest back from the registry. Empty means the
|
||||||
|
# tag is not there, which a caller reports differently from a mismatch.
|
||||||
|
mirror_digest_on_host() {
|
||||||
|
mirror_on_host <<REMOTE
|
||||||
|
set -uo pipefail
|
||||||
|
docker run --rm --network "\$MIRROR_NETWORK" "\$MIRROR_SKOPEO_IMAGE" \\
|
||||||
|
inspect --tls-verify=false --creds '$MIRROR_USER:$GITEA_TOKEN' \\
|
||||||
|
--format '{{.Digest}}' 'docker://$REGISTRY_HOST/$REGISTRY_REPO:$1' 2>/dev/null | tail -n1
|
||||||
|
REMOTE
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies one tag, leaving skopeo's output in MIRROR_COPY_LOG so a failure can
|
||||||
|
# be shown without streaming its progress into the report.
|
||||||
|
mirror_copy_on_host() {
|
||||||
|
MIRROR_COPY_LOG="$(mktemp -t cobble-mirror.XXXXXX)"
|
||||||
|
mirror_on_host <<REMOTE >"$MIRROR_COPY_LOG" 2>&1
|
||||||
|
set -uo pipefail
|
||||||
|
docker run --rm --network "\$MIRROR_NETWORK" "\$MIRROR_SKOPEO_IMAGE" \\
|
||||||
|
copy --all --retry-times 5 \\
|
||||||
|
--src-tls-verify=true --dest-tls-verify=false \\
|
||||||
|
--dest-creds '$MIRROR_USER:$GITEA_TOKEN' \\
|
||||||
|
'docker://$SOURCE_REGISTRY_HOST/$SOURCE_REPO:$1' \\
|
||||||
|
'docker://$REGISTRY_HOST/$REGISTRY_REPO:$2'
|
||||||
|
REMOTE
|
||||||
|
}
|
||||||
|
# END:mirror-remote
|
||||||
|
|
||||||
|
# MARK:mirror-main
|
||||||
|
require_repo_root
|
||||||
|
|
||||||
|
step "Resolving the version"
|
||||||
|
if [ -z "$VERSION_RAW" ]; then
|
||||||
|
VERSION_RAW="$(source_version)"
|
||||||
|
if [ -z "$VERSION_RAW" ]; then
|
||||||
|
fail "No version in package.json and none passed with --version."
|
||||||
|
fi
|
||||||
|
detail "Read from package.json: $VERSION_RAW"
|
||||||
|
fi
|
||||||
|
validate_version "$VERSION_RAW"
|
||||||
|
TAG="$(normalize_version "$VERSION_RAW")"
|
||||||
|
ok "Mirroring $TAG."
|
||||||
|
|
||||||
|
require_cmd git
|
||||||
|
require_cmd ssh "Install an ssh client."
|
||||||
|
require_cmd curl "Install curl."
|
||||||
|
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
fail "No Gitea token. Set GITEA_TOKEN to a token whose user can push packages to the $REGISTRY_REPO owner."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Checking the released image in $SOURCE_REGISTRY_HOST"
|
||||||
|
SOURCE_DIGEST="$(github_registry_digest "$TAG")" ||
|
||||||
|
fail "No $TAG in $SOURCE_REGISTRY_HOST/$SOURCE_REPO. Publish the GitHub release first: scripts/publish-github.sh builds the image."
|
||||||
|
if [ -z "$SOURCE_DIGEST" ]; then
|
||||||
|
fail "No $TAG in $SOURCE_REGISTRY_HOST/$SOURCE_REPO. Publish the GitHub release first: scripts/publish-github.sh builds the image."
|
||||||
|
fi
|
||||||
|
ok "$TAG is $SOURCE_DIGEST."
|
||||||
|
|
||||||
|
LATEST_DIGEST="$(github_registry_digest latest || true)"
|
||||||
|
|
||||||
|
TAGS_TO_COPY="$(mirror_fixed_tags "$TAG")"
|
||||||
|
MOVING_SKIPPED=""
|
||||||
|
if mirror_owns_moving_tags "$SOURCE_DIGEST" "$LATEST_DIGEST"; then
|
||||||
|
TAGS_TO_COPY="$(
|
||||||
|
printf '%s\n%s\n' "$TAGS_TO_COPY" "$(mirror_moving_tags "$TAG")" |
|
||||||
|
awk 'NF && !seen[$0]++'
|
||||||
|
)"
|
||||||
|
else
|
||||||
|
MOVING_SKIPPED="$(mirror_moving_tags "$TAG" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Mirroring $TAG into the Gitea registry"
|
||||||
|
printf ' %shost%s %s\n' "$C_DIM" "$C_RESET" "$HOST_USER@$HOST"
|
||||||
|
printf ' %sregistry%s %s/%s\n' "$C_DIM" "$C_RESET" "$REGISTRY_HOST" "$REGISTRY_REPO"
|
||||||
|
printf ' %ssource%s %s/%s\n' "$C_DIM" "$C_RESET" "$SOURCE_REGISTRY_HOST" "$SOURCE_REPO"
|
||||||
|
printf ' %stags%s %s\n' "$C_DIM" "$C_RESET" "$(printf '%s' "$TAGS_TO_COPY" | tr '\n' ' ')"
|
||||||
|
if [ -n "$MOVING_SKIPPED" ]; then
|
||||||
|
warn "$SOURCE_REGISTRY_HOST/$SOURCE_REPO:latest belongs to a newer release, so ${MOVING_SKIPPED% } is left where it is."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Checking the Gitea host"
|
||||||
|
if is_dry_run; then
|
||||||
|
detail "[dry run] ssh -o BatchMode=yes $HOST_USER@$HOST 'bash -s'"
|
||||||
|
remote_hostname="$HOST"
|
||||||
|
else
|
||||||
|
remote_hostname="$(
|
||||||
|
mirror_on_host <<'REMOTE'
|
||||||
|
hostname
|
||||||
|
REMOTE
|
||||||
|
)" || fail "Could not reach $HOST_USER@$HOST over ssh. The Gitea host is on the VPN: connect to it first."
|
||||||
|
ok "Reached $remote_hostname."
|
||||||
|
fi
|
||||||
|
|
||||||
|
confirm "Mirror $TAG into $REGISTRY_HOST/$REGISTRY_REPO?"
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
for dest_tag in $TAGS_TO_COPY; do
|
||||||
|
step "Copying $TAG to $dest_tag"
|
||||||
|
|
||||||
|
if is_dry_run; then
|
||||||
|
detail "[dry run] skopeo copy --all $SOURCE_REGISTRY_HOST/$SOURCE_REPO:$TAG -> $REGISTRY_HOST/$REGISTRY_REPO:$dest_tag"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! mirror_copy_on_host "$TAG" "$dest_tag"; then
|
||||||
|
sed -n '$!{/^$/d};p' "${MIRROR_COPY_LOG:-/dev/null}" 2>/dev/null | tail -n 8 >&2
|
||||||
|
warn "The copy to $dest_tag failed."
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The digest is what makes the two registries the same image: a copy that
|
||||||
|
# stored a different manifest list would read back differently here.
|
||||||
|
copied_digest="$(mirror_digest_on_host "$dest_tag")"
|
||||||
|
if [ "$copied_digest" != "$SOURCE_DIGEST" ]; then
|
||||||
|
warn "$dest_tag reads back as ${copied_digest:-nothing}, not $SOURCE_DIGEST."
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
ok "$dest_tag is $SOURCE_DIGEST."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "${MIRROR_COPY_LOG:-}" ] && [ -f "$MIRROR_COPY_LOG" ]; then
|
||||||
|
rm -f "$MIRROR_COPY_LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Mirrored $TAG"
|
||||||
|
printf ' %sregistry%s %s/%s\n' "$C_DIM" "$C_RESET" "$REGISTRY_HOST" "$REGISTRY_REPO"
|
||||||
|
printf ' %stags%s %s\n' "$C_DIM" "$C_RESET" "$(printf '%s' "$TAGS_TO_COPY" | tr '\n' ' ')"
|
||||||
|
printf ' %sdigest%s %s\n' "$C_DIM" "$C_RESET" "$SOURCE_DIGEST"
|
||||||
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
|
printf '\n%sDry run: copied nothing.%s\n' "$C_YELLOW" "$C_RESET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$failed" = "1" ]; then
|
||||||
|
fail "The Gitea registry does not match $SOURCE_REGISTRY_HOST/$SOURCE_REPO. The release itself is unaffected: the installer defaults to GHCR."
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nAn install that can reach the Gitea registry can pull it with:\n'
|
||||||
|
printf ' curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --image %s/%s:%s\n' \
|
||||||
|
"$REGISTRY_PUBLIC_HOST" "$REGISTRY_REPO" "$TAG"
|
||||||
|
# END:mirror-main
|
||||||
@@ -8,9 +8,10 @@
|
|||||||
# creates the Gitea release over the API.
|
# creates the Gitea release over the API.
|
||||||
#
|
#
|
||||||
# Gitea has no release workflow, so nothing is built here: the image is built
|
# Gitea has no release workflow, so nothing is built here: the image is built
|
||||||
# once by .github/workflows/release.yml and mirrored to
|
# once by .github/workflows/release.yml and copied into the Gitea registry by
|
||||||
# git.tben.me/moduletrace/cobble. This script only publishes the source and
|
# scripts/mirror-gitea.sh, run from the VPN side, since a GitHub runner can
|
||||||
# the release object.
|
# never reach it. This script only publishes the source and the release
|
||||||
|
# object.
|
||||||
#
|
#
|
||||||
# The token comes from GITEA_TOKEN, or from the Gitea section of ~/.config/tea
|
# The token comes from GITEA_TOKEN, or from the Gitea section of ~/.config/tea
|
||||||
# if the tea CLI is installed. It needs the `write:repository` scope on the
|
# if the tea CLI is installed. It needs the `write:repository` scope on the
|
||||||
@@ -221,6 +222,5 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
print_release_summary "$REMOTE" "$BRANCH" "https://${REMOTE_HOST}/${REPO_SLUG}/releases/tag/$TAG"
|
print_release_summary "$REMOTE" "$BRANCH" "https://${REMOTE_HOST}/${REPO_SLUG}/releases/tag/$TAG"
|
||||||
printf '\nThe image is built by the GitHub release; mirror it into the Gitea registry with:\n'
|
printf '\nThe image is built by the GitHub release, which cannot reach this registry.\nOnce that build has finished, copy it in over the VPN with:\n'
|
||||||
printf ' docker buildx imagetools create --tag git.tben.me/moduletrace/cobble:latest \\\n'
|
printf ' bun run mirror:gitea\n'
|
||||||
printf ' ghcr.io/moduletrace/cobble:latest\n'
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
# installer syntax), tags HEAD with the package.json version, pushes main and
|
# installer syntax), tags HEAD with the package.json version, pushes main and
|
||||||
# the tag to the `origin` remote, then creates the GitHub release. Publishing
|
# the tag to the `origin` remote, then creates the GitHub release. Publishing
|
||||||
# that release is what fires .github/workflows/release.yml, which builds the
|
# that release is what fires .github/workflows/release.yml, which builds the
|
||||||
# multi-arch image and pushes it to GHCR (and mirrors it to Gitea).
|
# multi-arch image and pushes it to GHCR, the registry the installer defaults
|
||||||
|
# to. It never pushes to Gitea: that registry is on the VPN, out of a
|
||||||
|
# runner's reach, so scripts/mirror-gitea.sh copies the image separately.
|
||||||
#
|
#
|
||||||
# Run scripts/publish-gitea.sh afterwards for the source mirror's own release
|
# Run scripts/publish-gitea.sh afterwards for the source mirror's own release
|
||||||
# object. There is no existing publish script in the repo: this and its Gitea
|
# object, and scripts/mirror-gitea.sh once the image build has finished.
|
||||||
# twin are it.
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
const ROOT = path.join(import.meta.dir, "..");
|
const ROOT = path.join(import.meta.dir, "..");
|
||||||
const COMMON = path.join(ROOT, "scripts/lib/publish-common.sh");
|
const COMMON = path.join(ROOT, "scripts/lib/publish-common.sh");
|
||||||
const RELEASE = path.join(ROOT, "scripts/lib/publish-release.sh");
|
const RELEASE = path.join(ROOT, "scripts/lib/publish-release.sh");
|
||||||
|
const MIRROR = path.join(ROOT, "scripts/mirror-gitea.sh");
|
||||||
const SCRIPTS = [
|
const SCRIPTS = [
|
||||||
path.join(ROOT, "scripts/publish-github.sh"),
|
path.join(ROOT, "scripts/publish-github.sh"),
|
||||||
path.join(ROOT, "scripts/publish-gitea.sh"),
|
path.join(ROOT, "scripts/publish-gitea.sh"),
|
||||||
@@ -99,13 +100,21 @@ async function pathWithoutJq(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("publish scripts syntax", () => {
|
describe("publish scripts syntax", () => {
|
||||||
for (const script of [...SCRIPTS, COMMON, RELEASE]) {
|
for (const script of [...SCRIPTS, MIRROR, COMMON, RELEASE]) {
|
||||||
test(`${path.relative(ROOT, script)} parses`, async () => {
|
test(`${path.relative(ROOT, script)} parses`, async () => {
|
||||||
const proc = Bun.spawn({ cmd: ["bash", "-n", script] });
|
const proc = Bun.spawn({ cmd: ["bash", "-n", script] });
|
||||||
expect(await proc.exited).toBe(0);
|
expect(await proc.exited).toBe(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("the mirror script sources the shared libraries", async () => {
|
||||||
|
const source = await Bun.file(MIRROR).text();
|
||||||
|
|
||||||
|
expect(source).toContain('SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"');
|
||||||
|
expect(source).toContain('. "$SCRIPT_DIR/lib/publish-common.sh"');
|
||||||
|
expect(source).toContain('. "$SCRIPT_DIR/lib/publish-release.sh"');
|
||||||
|
});
|
||||||
|
|
||||||
test("both publish scripts source the shared library", async () => {
|
test("both publish scripts source the shared library", async () => {
|
||||||
for (const script of SCRIPTS) {
|
for (const script of SCRIPTS) {
|
||||||
const source = await Bun.file(script).text();
|
const source = await Bun.file(script).text();
|
||||||
@@ -141,6 +150,156 @@ describe("publish scripts remote wiring", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mirror script wiring", () => {
|
||||||
|
test("CI never pushes to the Gitea registry", async () => {
|
||||||
|
const workflow = await Bun.file(
|
||||||
|
path.join(ROOT, ".github/workflows/release.yml"),
|
||||||
|
).text();
|
||||||
|
|
||||||
|
expect(workflow).not.toContain("git.tben.me");
|
||||||
|
expect(workflow).not.toContain("GITEA_TOKEN");
|
||||||
|
expect(workflow).not.toContain("imagetools create");
|
||||||
|
expect(workflow).toContain("Build and push to GHCR");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the mirror script copies every tag the workflow publishes", async () => {
|
||||||
|
const [workflow, mirror] = await Promise.all([
|
||||||
|
Bun.file(path.join(ROOT, ".github/workflows/release.yml")).text(),
|
||||||
|
Bun.file(MIRROR).text(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The workflow's own four tags, so a mirror tag can never be invented
|
||||||
|
// here and go missing there.
|
||||||
|
for (const tag of ["latest", "${version}", "${minor}", "${sha}"]) {
|
||||||
|
expect(workflow).toContain(tag);
|
||||||
|
}
|
||||||
|
expect(mirror).toContain("mirror_minor_tag");
|
||||||
|
expect(mirror).toContain("mirror_sha_tag");
|
||||||
|
expect(mirror).toContain("mirror_fixed_tags");
|
||||||
|
expect(mirror).toContain("mirror_moving_tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the mirror script asks before copying and verifies each digest", async () => {
|
||||||
|
const source = await Bun.file(MIRROR).text();
|
||||||
|
|
||||||
|
expect(source).toContain("confirm \"Mirror $TAG into");
|
||||||
|
expect(source).toContain("mirror_digest_on_host");
|
||||||
|
expect(source).toContain("skopeo");
|
||||||
|
expect(source).toContain("--all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the pull command it prints names the public host, not the docker one", async () => {
|
||||||
|
const source = await Bun.file(MIRROR).text();
|
||||||
|
|
||||||
|
// The copy reaches the registry by docker network name, which is not
|
||||||
|
// resolvable for an install; printing that would hand out a broken
|
||||||
|
// command.
|
||||||
|
expect(source).toContain("MIRROR_REGISTRY_PUBLIC_HOST");
|
||||||
|
expect(source).toContain('"$REGISTRY_PUBLIC_HOST" "$REGISTRY_REPO" "$TAG"');
|
||||||
|
expect(source).not.toContain('"${REGISTRY_HOST%%:*}" "$REGISTRY_REPO" "$TAG"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a token the mirror script refuses instead of guessing", async () => {
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: ["bash", MIRROR, "--yes"],
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, GITEA_TOKEN: "" },
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const [stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(stderr).toContain("No Gitea token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mirror lib tag handling", () => {
|
||||||
|
test("major.minor is read exactly as the release workflow reads it", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
|
||||||
|
|
||||||
|
for (const [version, expected] of [
|
||||||
|
["v0.1.5", "0.1"],
|
||||||
|
["v1.2.3", "1.2"],
|
||||||
|
["v10.20.30", "10.20"],
|
||||||
|
["v0.1.5-rc.1", "0.1"],
|
||||||
|
] as const) {
|
||||||
|
const out = await runBash({
|
||||||
|
script: [block, `mirror_minor_tag '${version}'`].join("\n"),
|
||||||
|
});
|
||||||
|
expect(out).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fixed tags are the version and its commit, moving ones the rest", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
|
||||||
|
|
||||||
|
const fixed = await runBash({
|
||||||
|
script: [block, "mirror_fixed_tags v0.1.5"].join("\n"),
|
||||||
|
});
|
||||||
|
expect(fixed.split("\n")[0]).toBe("v0.1.5");
|
||||||
|
|
||||||
|
const moving = await runBash({
|
||||||
|
script: [block, "mirror_moving_tags v0.1.5"].join("\n"),
|
||||||
|
});
|
||||||
|
expect(moving.split("\n")).toEqual(["latest", "0.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tag with no commit on disk still mirrors its version", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-tags" });
|
||||||
|
|
||||||
|
const out = await runBash({
|
||||||
|
script: [block, "mirror_fixed_tags v99.99.99"].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toBe("v99.99.99");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mirror lib moving tag guard", () => {
|
||||||
|
test("a release may move latest while it owns it", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
|
||||||
|
|
||||||
|
const out = await runBash({
|
||||||
|
script: [
|
||||||
|
block,
|
||||||
|
'mirror_owns_moving_tags sha256:aa sha256:aa && printf owns',
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toBe("owns");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a newer release's latest is never rolled back", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
|
||||||
|
|
||||||
|
const out = await runBash({
|
||||||
|
script: [
|
||||||
|
block,
|
||||||
|
'if mirror_owns_moving_tags sha256:aa sha256:bb; then printf owns; else printf refuses; fi',
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toBe("refuses");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no latest to compare, the release is taken as newest", async () => {
|
||||||
|
const block = await blockCaller({ file: MIRROR, name: "mirror-guard" });
|
||||||
|
|
||||||
|
const out = await runBash({
|
||||||
|
script: [
|
||||||
|
block,
|
||||||
|
'mirror_owns_moving_tags sha256:aa "" && printf owns',
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toBe("owns");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("publish lib version handling", () => {
|
describe("publish lib version handling", () => {
|
||||||
test("normalizes a version with and without the v", async () => {
|
test("normalizes a version with and without the v", async () => {
|
||||||
const block = await blockCaller({ name: "version" });
|
const block = await blockCaller({ name: "version" });
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import hasUnansweredMessage from "./has-unanswered-message";
|
||||||
|
|
||||||
|
const user = (id: number) => ({ id, author: "user" as const });
|
||||||
|
const bot = (id: number) => ({ id, author: "bot" as const });
|
||||||
|
|
||||||
|
describe("hasUnansweredMessage", () => {
|
||||||
|
test("a message sent while the last reply ran is answered, though the thread ends with the bot", () => {
|
||||||
|
/**
|
||||||
|
* A (10) was being answered when B (11) came in; the reply (12)
|
||||||
|
* was saved after B but its run never read it.
|
||||||
|
*/
|
||||||
|
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 10 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a message the last reply took in is not answered twice", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [user(10), user(11), bot(12)], answered_id: 11 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a burst after the answer is answered", () => {
|
||||||
|
expect(
|
||||||
|
hasUnansweredMessage({ history: [user(10), bot(12), user(13), user(14)], answered_id: 10 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a record, the user having the last word decides", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [user(1)], answered_id: null })).toBe(true);
|
||||||
|
expect(hasUnansweredMessage({ history: [user(1), bot(2)], answered_id: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing from the user, nothing to answer", () => {
|
||||||
|
expect(hasUnansweredMessage({ history: [], answered_id: null })).toBe(false);
|
||||||
|
expect(hasUnansweredMessage({ history: [bot(4)], answered_id: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The session's latest messages, oldest first.
|
||||||
|
*/
|
||||||
|
history: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "id" | "author">[];
|
||||||
|
/**
|
||||||
|
* The newest user message a finished reply answered
|
||||||
|
* (`grabAnsweredMessageId`), null when none is on record.
|
||||||
|
*/
|
||||||
|
answered_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a reply has anything left to answer. A user message that came in
|
||||||
|
* while the last reply ran is saved before that reply, so the thread ends
|
||||||
|
* with the bot's message even though the bot never read it: what the last
|
||||||
|
* reply read decides, not who wrote last. Without a record (no reply has
|
||||||
|
* finished in the session), the user having the last word does.
|
||||||
|
*/
|
||||||
|
export default function hasUnansweredMessage({ history, answered_id }: Params) {
|
||||||
|
const user_ids = history
|
||||||
|
.filter((message) => message.author == "user")
|
||||||
|
.map((message) => Number(message.id));
|
||||||
|
|
||||||
|
if (!user_ids.length) return false;
|
||||||
|
|
||||||
|
if (answered_id) {
|
||||||
|
return Math.max(...user_ids) > answered_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return history[history.length - 1]?.author == "user";
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import path from "node:path";
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabMessagesMedia, {
|
import grabMessagesMedia, {
|
||||||
type MessageMediaRow,
|
type MessageMediaRow,
|
||||||
} from "@/src/functions/backend/db/bots/grab-messages-media";
|
} from "@/src/functions/backend/db/bots/grab-messages-media";
|
||||||
import type { HarnessContentPart } from "@/src/harness/types";
|
import type { HarnessContentPart } from "@/src/harness/types";
|
||||||
import type { BotMessageWithAttachments } from "@/src/types";
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import describeAttachment from "./describe-attachment";
|
import describeAttachment from "./describe-attachment";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -74,9 +73,10 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
|
|||||||
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
|
return [{ type: "text", text: `[Attached video ${label}: videos can't be read yet.]` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const disk = Bun.file(path.join(grabDirNames().DATA_DIR, file.media_write_path || ""));
|
const absolute = mediaFilePath({ write_path: file.media_write_path });
|
||||||
|
const disk = absolute ? Bun.file(absolute) : null;
|
||||||
|
|
||||||
if (!file.media_write_path || !(await disk.exists())) {
|
if (!disk || !(await disk.exists())) {
|
||||||
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
|
return [{ type: "text", text: `[Attached file ${label} is missing on the server.]` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
|
|||||||
import { SessionNamePrompt } from "@/src/data/harness-prompts";
|
import { SessionNamePrompt } from "@/src/data/harness-prompts";
|
||||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||||
import generateText from "@/src/harness/text/generate-text";
|
import generateText from "@/src/harness/text/generate-text";
|
||||||
|
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||||
import parseSessionName from "./parse-session-name";
|
import parseSessionName from "./parse-session-name";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -30,7 +31,7 @@ export default async function nameBotSession({
|
|||||||
}: Params): Promise<boolean> {
|
}: Params): Promise<boolean> {
|
||||||
const session = (
|
const session = (
|
||||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
sql: `SELECT id, title, summary FROM bot_sessions WHERE id = ? LIMIT 1`,
|
sql: `SELECT id, user_id, title, summary FROM bot_sessions WHERE id = ? LIMIT 1`,
|
||||||
values: [session_id],
|
values: [session_id],
|
||||||
})
|
})
|
||||||
).payload?.[0];
|
).payload?.[0];
|
||||||
@@ -57,7 +58,22 @@ export default async function nameBotSession({
|
|||||||
)
|
)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
const result = await generateText({
|
/**
|
||||||
|
* The naming turn is a model call too, so it is recorded for this
|
||||||
|
* conversation. It runs after the job is complete, on purpose, so it
|
||||||
|
* has no job and no message of its own to charge it to.
|
||||||
|
*/
|
||||||
|
const result = await runWithHarnessUsageScope({
|
||||||
|
scope: {
|
||||||
|
user_id: session.user_id || undefined,
|
||||||
|
bot_id,
|
||||||
|
workspace_id: workspace_id || null,
|
||||||
|
session_id: session.id,
|
||||||
|
job_id: null,
|
||||||
|
message_id: null,
|
||||||
|
},
|
||||||
|
task: () =>
|
||||||
|
generateText({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: SessionNamePrompt },
|
{ role: "system", content: SessionNamePrompt },
|
||||||
{ role: "user", content: transcript },
|
{ role: "user", content: transcript },
|
||||||
@@ -65,6 +81,7 @@ export default async function nameBotSession({
|
|||||||
bot_id,
|
bot_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const name = parseSessionName({ text: result.text });
|
const name = parseSessionName({ text: result.text });
|
||||||
|
|||||||
@@ -290,6 +290,38 @@ export default async function processBotJob({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every failed try is on the Activity page, so a model or provider
|
||||||
|
* that keeps failing shows there and not only in one chat.
|
||||||
|
*/
|
||||||
|
if (failed) {
|
||||||
|
const final = failed.status == "failed";
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
title: final
|
||||||
|
? `Bot job #${job_id} failed`
|
||||||
|
: `Bot job #${job_id} failed, retrying (try ${tries})`,
|
||||||
|
error: timed_out ? new Error(timeout_message) : error,
|
||||||
|
user_id: user_id || undefined,
|
||||||
|
details: [
|
||||||
|
{ label: "Bot", value: bot_id },
|
||||||
|
{ label: "Kind", value: job.kind },
|
||||||
|
{ label: "Title", value: job.title || "" },
|
||||||
|
{ label: "Try", value: tries },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
job_id,
|
||||||
|
bot_id,
|
||||||
|
kind: job.kind,
|
||||||
|
session_id: job.session_id,
|
||||||
|
workspace_id: job.workspace_id,
|
||||||
|
schedule_id,
|
||||||
|
fatal: error instanceof BotJobFatalError,
|
||||||
|
harness_code: error instanceof HarnessError ? error.code : undefined,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (failed?.notice) {
|
if (failed?.notice) {
|
||||||
await pushBotNotice({ bot_id, ...failed.notice });
|
await pushBotNotice({ bot_id, ...failed.notice });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ async function push({
|
|||||||
})
|
})
|
||||||
).payload?.[0];
|
).payload?.[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspaces it leads, so each crew shows its shield.
|
||||||
|
*/
|
||||||
|
const admin_workspace_ids = (
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ workspace_id: number }>({
|
||||||
|
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin' AND workspace_id IS NOT NULL`,
|
||||||
|
values: [bot_id],
|
||||||
|
})
|
||||||
|
).payload || []
|
||||||
|
).map((row) => Number(row.workspace_id));
|
||||||
|
|
||||||
for (const viewer_id of audience) {
|
for (const viewer_id of audience) {
|
||||||
if (users && !users.has(String(viewer_id))) continue;
|
if (users && !users.has(String(viewer_id))) continue;
|
||||||
|
|
||||||
@@ -87,6 +99,7 @@ async function push({
|
|||||||
color: identity?.color || null,
|
color: identity?.color || null,
|
||||||
stone: identity?.stone || null,
|
stone: identity?.stone || null,
|
||||||
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null,
|
image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null,
|
||||||
|
admin_workspace_ids,
|
||||||
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
|
||||||
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
...(workspace_ids !== undefined ? { workspace_ids } : {}),
|
||||||
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs";
|
import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs";
|
||||||
import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed";
|
import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
import pushBotNotice from "./push-bot-notice";
|
import pushBotNotice from "./push-bot-notice";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the user about jobs that ran out of attempts by going silent
|
* Tells the user about jobs that ran out of attempts by going silent
|
||||||
* (bun-sqlite fails those itself, without a message).
|
* (bun-sqlite fails those itself, without a message), and logs each.
|
||||||
*/
|
*/
|
||||||
export default async function reportLostBotJobs() {
|
export default async function reportLostBotJobs() {
|
||||||
const jobs = await grabUnreportedFailedBotJobs();
|
const jobs = await grabUnreportedFailedBotJobs();
|
||||||
@@ -18,5 +19,16 @@ export default async function reportLostBotJobs() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await pushBotNotice({ bot_id: job.bot_id, ...notice });
|
await pushBotNotice({ bot_id: job.bot_id, ...notice });
|
||||||
|
|
||||||
|
await logError({
|
||||||
|
title: `Bot job #${job.id} lost`,
|
||||||
|
error: job.last_error || "The job's lock timed out.",
|
||||||
|
details: [
|
||||||
|
{ label: "Bot", value: job.bot_id },
|
||||||
|
{ label: "Kind", value: job.kind },
|
||||||
|
{ label: "Title", value: job.title || "" },
|
||||||
|
],
|
||||||
|
metadata: { job_id: job.id, bot_id: job.bot_id, kind: job.kind },
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
|
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
|
||||||
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
|
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
|
||||||
import HarnessError from "@/src/harness/harness-error";
|
import HarnessError from "@/src/harness/harness-error";
|
||||||
|
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||||
import BotJobFatalError from "./bot-job-fatal-error";
|
import BotJobFatalError from "./bot-job-fatal-error";
|
||||||
import runBotReply from "./run-bot-reply";
|
import runBotReply from "./run-bot-reply";
|
||||||
import runBotTask from "./run-bot-task";
|
import runBotTask from "./run-bot-task";
|
||||||
@@ -33,9 +34,25 @@ const runBotJob: BotJobRunner = async (context) => {
|
|||||||
const owned = { ...bot, id: bot.id };
|
const owned = { ...bot, id: bot.id };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return job.kind == "reply"
|
/**
|
||||||
? await runBotReply({ ...context, bot: owned, user_id })
|
* Everything the job's models do is recorded for this user, this
|
||||||
: await runBotTask({ ...context, bot: owned, user_id });
|
* bot, this conversation and this job. A reply's text loop adds
|
||||||
|
* the message it is producing to the one-turn scope it opens.
|
||||||
|
*/
|
||||||
|
return await runWithHarnessUsageScope({
|
||||||
|
scope: {
|
||||||
|
user_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id: job.workspace_id || null,
|
||||||
|
session_id: job.session_id || null,
|
||||||
|
job_id: job.id || null,
|
||||||
|
message_id: null,
|
||||||
|
},
|
||||||
|
task: () =>
|
||||||
|
job.kind == "reply"
|
||||||
|
? runBotReply({ ...context, bot: owned, user_id })
|
||||||
|
: runBotTask({ ...context, bot: owned, user_id }),
|
||||||
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
error instanceof HarnessError &&
|
error instanceof HarnessError &&
|
||||||
|
|||||||
+121
-28
@@ -2,15 +2,20 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
BotFilesNote,
|
botFilesNote,
|
||||||
ChatReplyNote,
|
chatReplyNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
CoordinatorReplyNote,
|
coordinatorReplyNote,
|
||||||
CoordinatorSoloNote,
|
CoordinatorSoloNote,
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskReminderNote,
|
TaskReminderNote,
|
||||||
|
workspaceAdminNote,
|
||||||
WorkspaceLearningNote,
|
WorkspaceLearningNote,
|
||||||
} from "@/src/data/harness-prompts";
|
} from "@/src/data/harness-prompts";
|
||||||
|
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
|
||||||
|
import workspaceAdminsNote from "./workspace-admins-note";
|
||||||
|
import type { WorkspaceAdminTools } from "@/src/harness/tools/bot/grab-workspace-admin-tools";
|
||||||
|
import { grabReplyMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
@@ -27,7 +32,14 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
|||||||
import loadAttachmentParts from "./load-attachment-parts";
|
import loadAttachmentParts from "./load-attachment-parts";
|
||||||
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
|
import hasUnansweredMessage from "./has-unanswered-message";
|
||||||
|
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
|
||||||
|
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
|
||||||
|
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
|
||||||
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
|
import type { HarnessMessage } from "@/src/harness/types";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
type Params = BotJobContext & {
|
type Params = BotJobContext & {
|
||||||
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
||||||
@@ -46,8 +58,12 @@ type Params = BotJobContext & {
|
|||||||
* instructions, start_task, and for the coordinator delegate_task) plus
|
* instructions, start_task, and for the coordinator delegate_task) plus
|
||||||
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
|
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
|
||||||
* its current computer).
|
* its current computer).
|
||||||
* Finishes silently when the last message is already the bot's, i.e. an
|
* Messages the user sends while it works join the run (`incoming`): one
|
||||||
* earlier reply answered everything.
|
* that changes the work changes the answer before it goes out, and one
|
||||||
|
* that's separate can be acknowledged at once (`send_message`) and done
|
||||||
|
* after the current work. The job records the newest
|
||||||
|
* one it read (`markReplyAnswered`). Finishes silently when an earlier
|
||||||
|
* reply already read everything (`hasUnansweredMessage`).
|
||||||
*/
|
*/
|
||||||
export default async function runBotReply({
|
export default async function runBotReply({
|
||||||
bot,
|
bot,
|
||||||
@@ -64,10 +80,48 @@ export default async function runBotReply({
|
|||||||
limit: AppData.BotReplyHistoryMessages,
|
limit: AppData.BotReplyHistoryMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (history[history.length - 1]?.author != "user") {
|
const answered_id = await grabAnsweredMessageId({ session_id });
|
||||||
|
|
||||||
|
if (!hasUnansweredMessage({ history, answered_id })) {
|
||||||
return { result: "", silent: true };
|
return { result: "", silent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest message the run has read, and of those the newest user
|
||||||
|
* message, which is what the reply answers.
|
||||||
|
*/
|
||||||
|
let seen_id = Math.max(0, ...history.map((message) => Number(message.id)));
|
||||||
|
let answering_id = newestUserMessageId({ messages: history });
|
||||||
|
|
||||||
|
const toTurns = async ({ messages }: { messages: BotMessageWithAttachments[] }) =>
|
||||||
|
botMessagesToHarness({
|
||||||
|
messages,
|
||||||
|
parts: await loadAttachmentParts({ messages }),
|
||||||
|
refs: await grabMessageRefNames({
|
||||||
|
texts: messages.filter((m) => m.author == "user").map((m) => m.text || ""),
|
||||||
|
user_id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user wrote since the run last looked.
|
||||||
|
*/
|
||||||
|
const incoming = async (): Promise<HarnessMessage[]> => {
|
||||||
|
const fresh = await grabBotMessages({
|
||||||
|
session_id,
|
||||||
|
after_id: seen_id,
|
||||||
|
author: "user",
|
||||||
|
limit: AppData.BotReplyHistoryMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fresh.length) return [];
|
||||||
|
|
||||||
|
seen_id = Math.max(seen_id, ...fresh.map((message) => Number(message.id)));
|
||||||
|
answering_id = Math.max(answering_id, newestUserMessageId({ messages: fresh }));
|
||||||
|
|
||||||
|
return await toTurns({ messages: fresh });
|
||||||
|
};
|
||||||
|
|
||||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
||||||
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
@@ -83,6 +137,11 @@ export default async function runBotReply({
|
|||||||
}).catch(() => "");
|
}).catch(() => "");
|
||||||
|
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools the coordinator proposed: their cards need
|
||||||
|
* the user just like a take-over.
|
||||||
|
*/
|
||||||
|
const proposals: string[] = [];
|
||||||
/**
|
/**
|
||||||
* Files the bot wrote and asked to send with its reply.
|
* Files the bot wrote and asked to send with its reply.
|
||||||
*/
|
*/
|
||||||
@@ -103,8 +162,16 @@ export default async function runBotReply({
|
|||||||
onAttach: ({ media_id }) => attachments.push(media_id),
|
onAttach: ({ media_id }) => attachments.push(media_id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the bot's types let it do here, and what it leads when it's this
|
||||||
|
* workspace's admin.
|
||||||
|
*/
|
||||||
|
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
|
||||||
|
let led: WorkspaceAdminTools | null = null;
|
||||||
|
|
||||||
const tools = await grabBotChatTools({
|
const tools = await grabBotChatTools({
|
||||||
bot,
|
bot,
|
||||||
|
powers,
|
||||||
user_id,
|
user_id,
|
||||||
computers,
|
computers,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -112,12 +179,22 @@ export default async function runBotReply({
|
|||||||
scopes,
|
scopes,
|
||||||
message_id: job.message_id || undefined,
|
message_id: job.message_id || undefined,
|
||||||
onTakeOver: ({ ref }) => take_overs.push(ref),
|
onTakeOver: ({ ref }) => take_overs.push(ref),
|
||||||
|
onPropose: ({ ref }) => proposals.push(ref),
|
||||||
|
onWorkspaceAdmin: (admin) => {
|
||||||
|
led = admin;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the run works on a computer: the user's folder in the bot's.
|
||||||
|
*/
|
||||||
|
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
||||||
|
|
||||||
const extensions = await openBotExtensions({
|
const extensions = await openBotExtensions({
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: computers[0]?.id || null,
|
computer_id: computers[0]?.id || null,
|
||||||
|
dirs,
|
||||||
taken: tools.map((tool) => tool.name),
|
taken: tools.map((tool) => tool.name),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -131,30 +208,35 @@ export default async function runBotReply({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* delegate_task is only there while the coordinator has workers.
|
* delegate_task is only there while the coordinator, or a workspace's
|
||||||
|
* admin, has bots to hand work to. Anyone else in a workspace with an
|
||||||
|
* admin is told who leads it.
|
||||||
*/
|
*/
|
||||||
const crew_note =
|
const can_delegate = tools.some((tool) => tool.name == "delegate_task");
|
||||||
bot.kind != "coordinator"
|
const lead = led as WorkspaceAdminTools | null;
|
||||||
? ""
|
const crew_note = powers.is_coordinator
|
||||||
: tools.some((tool) => tool.name == "delegate_task")
|
? can_delegate
|
||||||
? CoordinatorReplyNote
|
? coordinatorReplyNote({ bot_id: bot.id })
|
||||||
: CoordinatorSoloNote;
|
: CoordinatorSoloNote
|
||||||
|
: lead?.workspace && can_delegate
|
||||||
|
? workspaceAdminNote({ workspace_name: lead.workspace.name || "this workspace", members: lead.members })
|
||||||
|
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runTextLoop({
|
const result = await runTextLoop({
|
||||||
system: [
|
system: [
|
||||||
prompt,
|
prompt,
|
||||||
carry_over,
|
carry_over,
|
||||||
ChatReplyNote,
|
chatReplyNote({ bot_id: bot.id }),
|
||||||
BotFilesNote,
|
botFilesNote({ bot_id: bot.id }),
|
||||||
ComputerFilesNote({ computer_id: computers[0]?.id || 0, can_import: false }),
|
ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskReminderNote,
|
TaskReminderNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
composeComputersNote({
|
composeComputersNote({
|
||||||
computers,
|
computers,
|
||||||
is_coordinator: bot.kind == "coordinator",
|
is_coordinator: powers.is_coordinator,
|
||||||
in_workspace: Boolean(workspace_id),
|
in_workspace: Boolean(workspace_id),
|
||||||
}),
|
}),
|
||||||
crew_note,
|
crew_note,
|
||||||
@@ -164,27 +246,38 @@ export default async function runBotReply({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n"),
|
.join("\n\n"),
|
||||||
messages: botMessagesToHarness({
|
messages: await toTurns({ messages: history }),
|
||||||
messages: history,
|
tools: [
|
||||||
parts: await loadAttachmentParts({ messages: history }),
|
...tools,
|
||||||
refs: await grabMessageRefNames({
|
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
||||||
texts: history.filter((m) => m.author == "user").map((m) => m.text || ""),
|
...file_tools,
|
||||||
user_id,
|
...extensions.tools,
|
||||||
}),
|
...skills.tools,
|
||||||
}),
|
],
|
||||||
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
|
max_steps: await grabReplyMaxToolSteps(),
|
||||||
max_steps: AppData.BotReplyMaxToolSteps,
|
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
onStep: report,
|
onStep: report,
|
||||||
|
incoming,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (job.id && answering_id) {
|
||||||
|
await markReplyAnswered({ job_id: job.id, message_id: answering_id });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...withTakeOverRefs({ text: result.text, refs: take_overs }),
|
...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await extensions.close();
|
await extensions.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newestUserMessageId({ messages }: { messages: Pick<BotMessageWithAttachments, "id" | "author">[] }) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
...messages.filter((message) => message.author == "user").map((message) => Number(message.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers";
|
||||||
|
import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools";
|
||||||
|
import workspaceAdminsNote from "./workspace-admins-note";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
BotFilesNote,
|
botFilesNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
ScheduledRunNote,
|
ScheduledRunNote,
|
||||||
TasksNote,
|
TasksNote,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
WorkspaceLearningNote,
|
WorkspaceLearningNote,
|
||||||
} from "@/src/data/harness-prompts";
|
} from "@/src/data/harness-prompts";
|
||||||
|
import { grabHarnessMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
@@ -31,6 +35,7 @@ import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
|||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
import ScheduledRunFailedError from "./scheduled-run-failed-error";
|
import ScheduledRunFailedError from "./scheduled-run-failed-error";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
type Params = BotJobContext & {
|
type Params = BotJobContext & {
|
||||||
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
|
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "kind"> & { id: number };
|
||||||
@@ -61,12 +66,17 @@ export default async function runBotTask({
|
|||||||
report,
|
report,
|
||||||
}: Params): Promise<BotJobOutcome> {
|
}: Params): Promise<BotJobOutcome> {
|
||||||
const workspace_id = job.workspace_id || null;
|
const workspace_id = job.workspace_id || null;
|
||||||
|
/**
|
||||||
|
* Where the run works on a computer: the user's folder in the bot's.
|
||||||
|
*/
|
||||||
|
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
||||||
|
|
||||||
const computer = job.computer_id
|
const computer = job.computer_id
|
||||||
? await openComputerTools({
|
? await openComputerTools({
|
||||||
computer_id: job.computer_id,
|
computer_id: job.computer_id,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
dirs,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -80,11 +90,13 @@ export default async function runBotTask({
|
|||||||
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coordinator's task can also change workspaces: the findings
|
* What the bot's types let it do here. A coordinator's task can
|
||||||
* it was asked to record go there, not only in the report.
|
* also change workspaces, and a workspace admin's its workspace:
|
||||||
|
* the findings it was asked to record go there, not only in the
|
||||||
|
* report.
|
||||||
*/
|
*/
|
||||||
const coordinator_workspaces =
|
const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id });
|
||||||
bot.kind == "coordinator"
|
const coordinator_workspaces = powers.is_coordinator
|
||||||
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
|
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived)
|
||||||
: [];
|
: [];
|
||||||
const carry_over = await grabSessionCarryOver({
|
const carry_over = await grabSessionCarryOver({
|
||||||
@@ -114,6 +126,16 @@ export default async function runBotTask({
|
|||||||
*/
|
*/
|
||||||
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
|
const scopes = await grabRunScopes({ bot, user_id, workspace_id });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace admin's task keeps the workspace's context and its
|
||||||
|
* members' instructions; it hands nothing over (no session to report
|
||||||
|
* back to mid-task).
|
||||||
|
*/
|
||||||
|
const admin_tools =
|
||||||
|
!powers.is_coordinator && workspace_id && powers.is_workspace_admin
|
||||||
|
? (await grabWorkspaceAdminTools({ bot, user_id, workspace_id, powers, scopes })).tools
|
||||||
|
: [];
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
...(await grabBotTools({
|
...(await grabBotTools({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -121,10 +143,12 @@ export default async function runBotTask({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
computers,
|
computers,
|
||||||
session_id,
|
session_id,
|
||||||
is_coordinator: bot.kind == "coordinator",
|
is_coordinator: powers.is_coordinator,
|
||||||
|
admin_workspace_ids: powers.admin_workspace_ids,
|
||||||
scopes,
|
scopes,
|
||||||
})),
|
})),
|
||||||
...(bot.kind == "coordinator"
|
...admin_tools,
|
||||||
|
...(powers.is_coordinator
|
||||||
? [
|
? [
|
||||||
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
||||||
...(coordinator_workspaces.length
|
...(coordinator_workspaces.length
|
||||||
@@ -141,7 +165,7 @@ export default async function runBotTask({
|
|||||||
: []),
|
: []),
|
||||||
...(computers.length
|
...(computers.length
|
||||||
? [
|
? [
|
||||||
searchComputersTool({ bot_id: bot.id, computers, workspace_id }),
|
searchComputersTool({ bot_id: bot.id, computers, workspace_id, dirs }),
|
||||||
askToTakeOverTool({
|
askToTakeOverTool({
|
||||||
computers: take_over_computers,
|
computers: take_over_computers,
|
||||||
in_task: true,
|
in_task: true,
|
||||||
@@ -158,6 +182,7 @@ export default async function runBotTask({
|
|||||||
scopes,
|
scopes,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
user_id,
|
user_id,
|
||||||
|
dirs,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -175,6 +200,7 @@ export default async function runBotTask({
|
|||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: job.computer_id || null,
|
computer_id: job.computer_id || null,
|
||||||
|
dirs,
|
||||||
taken: tools.map((tool) => tool.name),
|
taken: tools.map((tool) => tool.name),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -190,14 +216,19 @@ export default async function runBotTask({
|
|||||||
carry_over,
|
carry_over,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
scheduled ? ScheduledRunNote : "",
|
scheduled ? ScheduledRunNote : "",
|
||||||
BotFilesNote,
|
botFilesNote({ bot_id: bot.id }),
|
||||||
ComputerFilesNote({ computer_id: job.computer_id || 0 }),
|
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
|
||||||
TasksNote,
|
TasksNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
bot.kind == "coordinator"
|
powers.is_coordinator
|
||||||
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
||||||
|
: admin_tools.some((tool) => tool.name == "update_workspace")
|
||||||
|
? `If this brief asked you to record something in this workspace's context, do it yourself with update_workspace before you report: the report alone changes nothing.`
|
||||||
: "",
|
: "",
|
||||||
|
powers.is_coordinator || powers.is_workspace_admin
|
||||||
|
? ""
|
||||||
|
: await workspaceAdminsNote({ bot_id: bot.id, workspace_id }),
|
||||||
computer
|
computer
|
||||||
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
||||||
: workspace_id
|
: workspace_id
|
||||||
@@ -227,6 +258,7 @@ export default async function runBotTask({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tools: [...tools, ...extensions.tools, ...skills.tools],
|
tools: [...tools, ...extensions.tools, ...skills.tools],
|
||||||
|
max_steps: await grabHarnessMaxToolSteps(),
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
type Params = {
|
type Params = {
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* The take-over refs the bot asked for in this run (`askToTakeOverTool`).
|
* The take-over refs the bot asked for in this run (`askToTakeOverTool`),
|
||||||
|
* and the proposals it made for the user to confirm (`proposalRef`).
|
||||||
*/
|
*/
|
||||||
refs: string[];
|
refs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A job's message with every take-over the bot asked for in it: refs the
|
* A job's message with every take-over the bot asked for in it and every
|
||||||
* bot didn't write itself are added at the end, so the user always gets
|
* proposal it made: refs the bot didn't write itself are added at the
|
||||||
* the button. A message asking for a take-over needs the user.
|
* end, so the user always gets the button. A message asking for either
|
||||||
|
* needs the user.
|
||||||
*/
|
*/
|
||||||
export default function withTakeOverRefs({ text, refs }: Params) {
|
export default function withTakeOverRefs({ text, refs }: Params) {
|
||||||
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { workspaceAdminsLine } from "@/src/data/harness-prompts";
|
||||||
|
import grabWorkspaceAdmins from "@/src/functions/backend/db/bot-types/grab-workspace-admins";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a bot in a workspace that has admins other than itself: the line
|
||||||
|
* naming them (`workspaceAdminsLine`).
|
||||||
|
*/
|
||||||
|
export default async function workspaceAdminsNote({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: {
|
||||||
|
bot_id: number | string | "" | undefined;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
}) {
|
||||||
|
if (!workspace_id) return "";
|
||||||
|
|
||||||
|
const admins = (await grabWorkspaceAdmins({ workspace_id })).filter((admin) => admin.id != Number(bot_id));
|
||||||
|
|
||||||
|
return admins.length ? workspaceAdminsLine({ admins }) : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
|
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||||
|
import Divider from "@moduletrace/twui/layout/Divider";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||||
|
*/
|
||||||
|
pathname?: string | null;
|
||||||
|
/**
|
||||||
|
* The page's own name, for the last crumb: what the address can't say,
|
||||||
|
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||||
|
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trail above a page's content: `Home`, then every section the address
|
||||||
|
* passes through, then the page itself, named by `title`.
|
||||||
|
*
|
||||||
|
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||||
|
* sections come from the address and only the page's own name is given
|
||||||
|
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||||
|
*/
|
||||||
|
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||||
|
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className={twMerge("w-full shrink-0 pt-5 pb-1")}>
|
||||||
|
<Breadcrumbs
|
||||||
|
pageUrl={pathname || AdminHomePath}
|
||||||
|
currentTitle={title}
|
||||||
|
excludeRegexMatch={/^admin$/}
|
||||||
|
linkProps={{
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||||
|
}}
|
||||||
|
currentLinkProps={{
|
||||||
|
title,
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||||
|
}}
|
||||||
|
divider={
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-ink-secondary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Its size in pixels; 14 sits beside a 14px name.
|
||||||
|
*/
|
||||||
|
size?: number;
|
||||||
|
/**
|
||||||
|
* The tooltip and accessible name.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark of a workspace's admin bot: a filled success-green shield with
|
||||||
|
* its check cut out in the background's colour, set beside the bot's name
|
||||||
|
* (never on its stone, which takes no badges). A role, not a status, and
|
||||||
|
* never the bot's own colour.
|
||||||
|
*/
|
||||||
|
export default function AdminShield({
|
||||||
|
size = 17,
|
||||||
|
label = "Workspace admin",
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<ShieldCheck
|
||||||
|
size={size}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
className={twMerge(
|
||||||
|
"shrink-0 inline-block align-middle",
|
||||||
|
"fill-success text-on-ink dark:fill-success-dark",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
</ShieldCheck>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin line sweeping across, set just under a pane header while its
|
||||||
|
* page loads. Takes no height (it overlays the top of what follows), so
|
||||||
|
* nothing moves when it goes. Under reduced motion it holds still.
|
||||||
|
*/
|
||||||
|
export default function LoadingLine({ label = "Loading", className }: Props) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={label}
|
||||||
|
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
|
||||||
|
>
|
||||||
|
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
|
||||||
|
<Span
|
||||||
|
className={twMerge(
|
||||||
|
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
|
||||||
|
"animate-loading-line",
|
||||||
|
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
@@ -15,9 +14,5 @@ export default async function pruneBotsQueue() {
|
|||||||
values: [cutoff],
|
values: [cutoff],
|
||||||
});
|
});
|
||||||
|
|
||||||
bunext.bunextLog.info(
|
|
||||||
`Cron: pruned ${res.payload?.length || 0} finished bot job(s)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
import bunext from "@moduletrace/bunext";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||||
|
import migrateComputerLayout from "@/src/functions/backend/db/computers/migrate-computer-layout";
|
||||||
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
import refreshComputerContainer from "@/src/functions/backend/db/computers/refresh-computer-container";
|
||||||
|
|
||||||
type Row = Pick<
|
type Row = Pick<
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
"id" | "docker_id" | "status" | "ip_address"
|
"id" | "docker_id" | "status" | "ip_address" | "metadata"
|
||||||
> & { computer_id: number };
|
> & { computer_id: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads every computer container's state from docker and records it
|
* Reads every computer container's state from docker and records it
|
||||||
* (`refreshComputerContainer`), telling owners about changes. Containers
|
* (`refreshComputerContainer`), telling owners about changes. Containers
|
||||||
* still being set up, or that failed, are left alone.
|
* still being set up, or that failed, are left alone. A container that
|
||||||
|
* still mounts its computer's whole home is recreated with only the
|
||||||
|
* workspace (`migrateComputerLayout`, once per computer).
|
||||||
*/
|
*/
|
||||||
export default async function syncComputers() {
|
export default async function syncComputers() {
|
||||||
const rows =
|
const rows =
|
||||||
(
|
(
|
||||||
await BunSQLite.sql<Row>({
|
await BunSQLite.sql<Row>({
|
||||||
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, c.id AS computer_id
|
sql: `SELECT co.id, co.docker_id, co.status, co.ip_address, co.metadata, c.id AS computer_id
|
||||||
FROM containers co JOIN computers c ON c.container_id = co.id
|
FROM containers co JOIN computers c ON c.container_id = co.id
|
||||||
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
|
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
|
||||||
})
|
})
|
||||||
@@ -25,7 +28,9 @@ export default async function syncComputers() {
|
|||||||
|
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
|
|
||||||
for (const { computer_id, ...container } of rows) {
|
for (const { computer_id, metadata, ...container } of rows) {
|
||||||
|
if (await migrateComputerLayout({ computer_id, metadata })) continue;
|
||||||
|
|
||||||
const refreshed = await refreshComputerContainer({
|
const refreshed = await refreshComputerContainer({
|
||||||
computer_id,
|
computer_id,
|
||||||
container,
|
container,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import bunext from "@moduletrace/bunext";
|
|
||||||
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
|
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes every enabled provider's model list. Free models on OpenRouter
|
* Refreshes every enabled provider's model list. Free models on OpenRouter
|
||||||
* come and go, and automatic picks follow them from this list.
|
* come and go, and automatic picks follow them from this list. A provider
|
||||||
|
* that fails doesn't fail the run, so its error goes to the logs here.
|
||||||
*/
|
*/
|
||||||
export default async function cronSyncHarnessModels() {
|
export default async function cronSyncHarnessModels() {
|
||||||
const results = await syncAllHarnessModels();
|
const results = await syncAllHarnessModels();
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.error) {
|
if (!result.error) continue;
|
||||||
bunext.bunextLog.error(
|
|
||||||
`Cron: model sync for provider #${result.provider_id} failed: ${result.error}`,
|
await logError({
|
||||||
);
|
title: `Cron: model sync for provider #${result.provider_id} failed`,
|
||||||
} else {
|
category: "cron",
|
||||||
bunext.bunextLog.info(
|
error: result.error,
|
||||||
`Cron: provider #${result.provider_id} lists ${result.count} model(s)`,
|
metadata: { provider_id: result.provider_id },
|
||||||
);
|
}).catch(() => {});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
+89
-5
@@ -96,12 +96,34 @@ export const AppData = {
|
|||||||
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
|
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
|
||||||
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
|
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
|
||||||
/**
|
/**
|
||||||
* A computer's home directory (`ComputerHomeDir` in its container) is
|
* Only a computer's workspace (`ComputerWorkspaceDir` in its
|
||||||
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's
|
* container) is kept, at `DATA_DIR/<ComputerDataDir>/<id>`, owned by
|
||||||
* user. It stays when the computer is deleted.
|
* the sandbox's user. It stays when the computer is deleted. The rest
|
||||||
|
* of its home (`ComputerHomeDir`: dotfiles, browser profile, the env
|
||||||
|
* files) lives in the container and goes with it. Every bot works in
|
||||||
|
* `bots/<bot id>` there, a user's work in `bots/<bot id>/users/<user
|
||||||
|
* id>`, and a workspace's bots share `workspaces/<workspace id>`
|
||||||
|
* (`computerRunDirs`).
|
||||||
*/
|
*/
|
||||||
ComputerDataDir: "computers",
|
ComputerDataDir: "computers",
|
||||||
ComputerHomeDir: "/home/gem",
|
ComputerHomeDir: "/home/gem",
|
||||||
|
ComputerWorkspaceDir: "/home/gem/workspace",
|
||||||
|
/**
|
||||||
|
* Written at the root of a computer's workspace once it uses that
|
||||||
|
* layout, so an older computer's whole home is told apart and moved
|
||||||
|
* aside (`prepareComputerDataDir`).
|
||||||
|
*/
|
||||||
|
ComputerWorkspaceMarker: ".cobble-workspace",
|
||||||
|
/**
|
||||||
|
* An older computer's whole home is moved aside to
|
||||||
|
* `<id><ComputerOldHomeSuffix><timestamp>` and kept, never archived.
|
||||||
|
*/
|
||||||
|
ComputerOldHomeSuffix: "-home-",
|
||||||
|
/**
|
||||||
|
* Recorded in a container's `metadata.layout` once it mounts only the
|
||||||
|
* workspace.
|
||||||
|
*/
|
||||||
|
ComputerWorkspaceLayout: "workspace",
|
||||||
ComputerUserUid: 1000,
|
ComputerUserUid: 1000,
|
||||||
ComputerUserGid: 1000,
|
ComputerUserGid: 1000,
|
||||||
/**
|
/**
|
||||||
@@ -246,7 +268,10 @@ export const AppData = {
|
|||||||
HarnessCooldownMs: 60 * 1000,
|
HarnessCooldownMs: 60 * 1000,
|
||||||
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
||||||
/**
|
/**
|
||||||
* Model turns in one text run before it must stop and report.
|
* Model turns in one text run before it must stop and report. The
|
||||||
|
* install may save its own limit (the `harness_max_tool_steps` setting,
|
||||||
|
* Settings > General), which wins over this one; a task run reads it
|
||||||
|
* through `grabHarnessMaxToolSteps`.
|
||||||
*/
|
*/
|
||||||
HarnessMaxToolSteps: 200,
|
HarnessMaxToolSteps: 200,
|
||||||
/**
|
/**
|
||||||
@@ -289,6 +314,20 @@ export const AppData = {
|
|||||||
SkillBodyMaxChars: 60000,
|
SkillBodyMaxChars: 60000,
|
||||||
SkillBodyShownChars: 30000,
|
SkillBodyShownChars: 30000,
|
||||||
SkillDescriptionMaxChars: 300,
|
SkillDescriptionMaxChars: 300,
|
||||||
|
/**
|
||||||
|
* How long the super admin has to confirm an MCP server or tool the
|
||||||
|
* coordinator proposed (`extension_proposals`). After that its card
|
||||||
|
* reads expired and the bot has to propose it again, so a card left in
|
||||||
|
* an old thread can't be confirmed weeks later.
|
||||||
|
*/
|
||||||
|
ExtensionProposalTtlMs: 24 * 60 * 60 * 1000,
|
||||||
|
/**
|
||||||
|
* A proposal's background check: the bot's summary, and the sources it
|
||||||
|
* read (at least one).
|
||||||
|
*/
|
||||||
|
ExtensionResearchMinChars: 40,
|
||||||
|
ExtensionResearchMaxChars: 6000,
|
||||||
|
ExtensionResearchSourcesMax: 10,
|
||||||
/**
|
/**
|
||||||
* `search_computers`: how long the command may run on each computer,
|
* `search_computers`: how long the command may run on each computer,
|
||||||
* and how much of each computer's output the bot reads.
|
* and how much of each computer's output the bot reads.
|
||||||
@@ -328,6 +367,21 @@ export const AppData = {
|
|||||||
* How many older messages each scroll to the top of the thread loads.
|
* How many older messages each scroll to the top of the thread loads.
|
||||||
*/
|
*/
|
||||||
ConversationOlderMessages: 30,
|
ConversationOlderMessages: 30,
|
||||||
|
/**
|
||||||
|
* The most model calls a message's details modal lists one by one. A
|
||||||
|
* long task can take hundreds; the totals still count every one.
|
||||||
|
*/
|
||||||
|
MessageDetailsMaxCalls: 200,
|
||||||
|
/**
|
||||||
|
* Days of usage the usage page totals by default, and the most it will
|
||||||
|
* total at once.
|
||||||
|
*/
|
||||||
|
UsageDefaultDays: 30,
|
||||||
|
UsageMaxDays: 365,
|
||||||
|
/**
|
||||||
|
* Models listed per page on the usage page, the busiest first.
|
||||||
|
*/
|
||||||
|
UsageModelPageSize: 20,
|
||||||
/**
|
/**
|
||||||
* How much of a bot's last message the rail's lists carry, as plain
|
* How much of a bot's last message the rail's lists carry, as plain
|
||||||
* text (`messagePreview`): a row shows one line of it.
|
* text (`messagePreview`): a row shows one line of it.
|
||||||
@@ -379,8 +433,23 @@ export const AppData = {
|
|||||||
WorkspaceActivityItems: 6,
|
WorkspaceActivityItems: 6,
|
||||||
/**
|
/**
|
||||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||||
|
* The install may save its own limit (the `bot_reply_max_tool_steps`
|
||||||
|
* setting, Settings > General), which wins over this one; a reply
|
||||||
|
* reads it through `grabReplyMaxToolSteps`.
|
||||||
*/
|
*/
|
||||||
BotReplyMaxToolSteps: 8,
|
BotReplyMaxToolSteps: 20,
|
||||||
|
/**
|
||||||
|
* Messages a chat reply may send while it works (`send_message`):
|
||||||
|
* acknowledging something the user wrote meanwhile, a heads-up. Its
|
||||||
|
* answer still comes at the end.
|
||||||
|
*/
|
||||||
|
BotReplyInterimMessagesMax: 3,
|
||||||
|
/**
|
||||||
|
* The most steps a saved run limit may ask for, for a task run or a
|
||||||
|
* chat reply. A typo in the settings page can't turn one run into an
|
||||||
|
* hour of tool calls, and a huge limit still ends with a report.
|
||||||
|
*/
|
||||||
|
BotStepLimitMax: 1000,
|
||||||
/**
|
/**
|
||||||
* Chat attachments: per file, per message, and how much of a
|
* Chat attachments: per file, per message, and how much of a
|
||||||
* document's text a reply reads.
|
* document's text a reply reads.
|
||||||
@@ -441,6 +510,12 @@ export const AppData = {
|
|||||||
* Most folders deep a path may go, so nothing buries a file.
|
* Most folders deep a path may go, so nothing buries a file.
|
||||||
*/
|
*/
|
||||||
BotFilePathMaxDepth: 8,
|
BotFilePathMaxDepth: 8,
|
||||||
|
/**
|
||||||
|
* Most folders deep the files browser opens: a computer's workspace
|
||||||
|
* starts four down (`bots/<bot>/users/<user>`) before a bot's own
|
||||||
|
* folders begin.
|
||||||
|
*/
|
||||||
|
StorageBrowserMaxDepth: 16,
|
||||||
/**
|
/**
|
||||||
* The files modal and the bot's file list: one page of them.
|
* The files modal and the bot's file list: one page of them.
|
||||||
*/
|
*/
|
||||||
@@ -532,6 +607,13 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
DataBackupNamePrefix: "cobble-data-",
|
DataBackupNamePrefix: "cobble-data-",
|
||||||
DataBackupsKept: 5,
|
DataBackupsKept: 5,
|
||||||
|
/**
|
||||||
|
* How often the backups page asks how a whole-data archive or restore
|
||||||
|
* is going, and how long it waits for the app to come back after a
|
||||||
|
* restore restarted it before it reloads anyway.
|
||||||
|
*/
|
||||||
|
DataBackupPollMs: 2000,
|
||||||
|
DataRestoreWaitMs: 120000,
|
||||||
/**
|
/**
|
||||||
* Responses smaller than this go out uncompressed: the encoding
|
* Responses smaller than this go out uncompressed: the encoding
|
||||||
* costs more than it saves.
|
* costs more than it saves.
|
||||||
@@ -550,4 +632,6 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
CompressCacheMaxFiles: 600,
|
CompressCacheMaxFiles: 600,
|
||||||
CompressCacheMaxBytes: 32 * 1024 * 1024,
|
CompressCacheMaxBytes: 32 * 1024 * 1024,
|
||||||
|
|
||||||
|
DefaultIconSize: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ Permission to draft is not permission to send, permission to analyze is not perm
|
|||||||
## 2. Delegate well
|
## 2. Delegate well
|
||||||
|
|
||||||
- Pick by role, instructions and tools, not by name.
|
- Pick by role, instructions and tools, not by name.
|
||||||
|
- Say where the work runs, and mean it. \`delegate_task\` takes an optional \`workspace_id\`, and that one argument decides the whole context the worker has: its own chat, or a workspace. A task in the worker's own chat has only that worker's own memory, files and directly assigned computers. A task in a workspace has the workspace's shared context, the worker's instructions and memory for that workspace, the project's files, and the workspace's computers as well as the worker's. The result also lands in a different conversation: the worker's own chat, or its conversation in that workspace beside the rest of that project. Choose by where the work belongs, not by which is easier to type.
|
||||||
|
- Handed over from a conversation inside a workspace, the task always runs in that workspace and \`workspace_id\` is not accepted: say so, don't pretend you moved it.
|
||||||
|
- Delegating into a workspace the worker isn't in adds it to that workspace as a side effect. Say that in your reply when it happens; don't add members silently for a one-off.
|
||||||
- Give a full brief: objective, inputs, deliverable, acceptance criteria, constraints.
|
- Give a full brief: objective, inputs, deliverable, acceptance criteria, constraints.
|
||||||
- Check \`list_tasks\` first. Never give two bots the same job, and don't restart work an open task already covers.
|
- Check \`list_tasks\` first. Never give two bots the same job, and don't restart work an open task already covers.
|
||||||
- Track what comes back, judge it against the request, and integrate it. Don't pass a worker's unchecked claim on as fact.
|
- Track what comes back, judge it against the request, and integrate it. Don't pass a worker's unchecked claim on as fact.
|
||||||
@@ -40,6 +43,7 @@ Permission to draft is not permission to send, permission to analyze is not perm
|
|||||||
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
|
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
|
||||||
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
|
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
|
||||||
- Workspace context is information, not authorization.
|
- Workspace context is information, not authorization.
|
||||||
|
- The install's skills (runbooks), MCP servers and custom tools are yours to keep: read them with \`list_extensions\`, decide who gets one with \`set_extension_access\`, and remove one with \`delete_extension\`. Adding one, or changing what it does (a skill's words, where a server or tool sends), is the user's call: run a background check first (for a server or tool the publisher's own docs, the official URL, what it needs; for a skill a review of every step and where its content came from), then \`propose_skill\`, \`propose_mcp_server\` or \`propose_custom_tool\`, which puts a card in your reply for the user to confirm. Tell them what it does, where it sends, which keys it uses (saved and missing), and what your check found. Never ask for a key's value in chat: missing keys are added in Settings, under Environment. It's added only once the user's message says they confirmed it. \`update_skill\`, \`update_mcp_server\` and \`update_custom_tool\` change the rest (on or off, and for a server or tool its name, description and arguments) directly.
|
||||||
|
|
||||||
Instruction layers are separate, and changing one does not change the others:
|
Instruction layers are separate, and changing one does not change the others:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
botFilesNote,
|
||||||
|
chatReplyNote,
|
||||||
|
ComputerFilesNote,
|
||||||
|
coordinatorReplyNote,
|
||||||
|
TasksNote,
|
||||||
|
} from "./harness-prompts";
|
||||||
|
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every note a bot reads names it by its own id, so a bot copying an
|
||||||
|
* example never points at another bot's folder, conversation or task.
|
||||||
|
*/
|
||||||
|
const bot_ids = [1, 5, 42] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bot ids a note's own-folder examples point at, in order.
|
||||||
|
*/
|
||||||
|
function folderOwners(note: string) {
|
||||||
|
return [...note.matchAll(/\[folder:bot:(\d+):/g)].map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("harness prompts", () => {
|
||||||
|
for (const bot_id of bot_ids) {
|
||||||
|
test(`chat reply note names bot ${bot_id}'s own folder`, () => {
|
||||||
|
const owners = folderOwners(chatReplyNote({ bot_id }));
|
||||||
|
|
||||||
|
expect(owners).toEqual([String(bot_id)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`files note names bot ${bot_id}'s own folder`, () => {
|
||||||
|
const owners = folderOwners(botFilesNote({ bot_id }));
|
||||||
|
|
||||||
|
expect(owners).toEqual([String(bot_id)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`coordinator note names itself as bot ${bot_id}`, () => {
|
||||||
|
const note = coordinatorReplyNote({ bot_id });
|
||||||
|
|
||||||
|
expect(note).toContain(`[bot:${bot_id}]`);
|
||||||
|
expect(note).not.toContain("[bot:3]");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a note that already names the running bot is unchanged by its id", () => {
|
||||||
|
expect(botFilesNote({ bot_id: 5 })).toContain("[folder:bot:5:reports]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the computer files note names the computer and the run's folders", () => {
|
||||||
|
const note = ComputerFilesNote({
|
||||||
|
computer_id: 8,
|
||||||
|
dirs: computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(note).toContain("[folder:computer:8:bots/3/users/7/seo/coderank/raw]");
|
||||||
|
expect(note).toContain("/home/gem/workspace/bots/3/users/7");
|
||||||
|
expect(note).toContain("/home/gem/workspace/workspaces/2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notes with nothing bot-specific carry no ref example", () => {
|
||||||
|
expect(TasksNote).not.toContain("[folder:bot:");
|
||||||
|
expect(TasksNote).not.toContain("[bot:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import botRef from "@/src/utils/bot-ref";
|
import botRef from "@/src/utils/bot-ref";
|
||||||
import folderRef from "@/src/utils/folder-ref";
|
import folderRef from "@/src/utils/folder-ref";
|
||||||
|
import { computerWorkspaceRelative, type ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompts the helper harnesses send. The text model gets their output in
|
* Prompts the helper harnesses send. The text model gets their output in
|
||||||
@@ -19,9 +20,19 @@ export const ReadImagePrompt = `Describe this image for someone who can't see it
|
|||||||
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to a bot's prompt when it answers the user in chat.
|
* The folder-ref example a note shows, always over the running bot's own
|
||||||
|
* folder: a literal owner id in the prompt teaches the bot to name another
|
||||||
|
* bot's folder as if it were its own.
|
||||||
*/
|
*/
|
||||||
export const ChatReplyNote = `# Replying in chat
|
function ownFolderRef({ bot_id }: { bot_id: number | string }) {
|
||||||
|
return folderRef({ volume: "bot", owner_id: bot_id, path: "reports" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a bot's prompt when it answers the user in chat. `bot_id` is the
|
||||||
|
* bot itself: the examples it reads are its own refs.
|
||||||
|
*/
|
||||||
|
export const chatReplyNote = ({ bot_id }: { bot_id: number | string }) => `# Replying in chat
|
||||||
|
|
||||||
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
||||||
|
|
||||||
@@ -33,7 +44,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
|
|||||||
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
|
- To add a worker to the crew, use create_bot once the user has asked for it or agreed, and add_bot_to_workspace to place it. A bot exists only when create_bot made it: never describe one as if it did, and never write it into a workspace's context.
|
||||||
- Never claim you did something you didn't do.
|
- Never claim you did something you didn't do.
|
||||||
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
|
- The user can mention a bot as [bot:ID] and a workspace as [workspace:ID]; a [Mentions: ...] line after their message says who and what those are. Write the same refs when you name them back.
|
||||||
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: that opens the folder in Files and downloads nothing.
|
- Documents, images and reports you make are files: save them with write_file, make_pdf or generate_image, and send one to the user with it ("attach"). When you name a file, write its [file:ID] ref, e.g. [file:12], so the user can open it. When you name a folder, write a folder ref instead, e.g. ${ownFolderRef({ bot_id })}: that opens the folder in Files and downloads nothing.
|
||||||
|
|
||||||
## Keep the work moving
|
## Keep the work moving
|
||||||
|
|
||||||
@@ -41,9 +52,10 @@ Be proactive. Follow where the conversation is heading and, once you've answered
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to every run, chat replies and tasks alike: where its files live,
|
* Added to every run, chat replies and tasks alike: where its files live,
|
||||||
* that they persist, and how to search them.
|
* that they persist, and how to search them. `bot_id` is the bot itself,
|
||||||
|
* so the folder-ref example it reads is its own folder.
|
||||||
*/
|
*/
|
||||||
export const BotFilesNote = `# Your files
|
export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your files
|
||||||
|
|
||||||
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
|
||||||
|
|
||||||
@@ -53,7 +65,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
|||||||
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
|
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
|
||||||
- A file and a folder are two different refs, and you never guess which one from the name:
|
- A file and a folder are two different refs, and you never guess which one from the name:
|
||||||
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
||||||
- A folder is a folder ref, e.g. ${folderRef({ volume: "bot", owner_id: 5, path: "reports" })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
||||||
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
||||||
- A file you only described is not saved. Save it first, then say it's saved.
|
- A file you only described is not saved. Save it first, then say it's saved.
|
||||||
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
||||||
@@ -71,15 +83,28 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
|||||||
*/
|
*/
|
||||||
export const ComputerFilesNote = ({
|
export const ComputerFilesNote = ({
|
||||||
computer_id,
|
computer_id,
|
||||||
|
dirs,
|
||||||
can_import = true,
|
can_import = true,
|
||||||
}: {
|
}: {
|
||||||
computer_id: number | string;
|
computer_id: number | string;
|
||||||
|
/**
|
||||||
|
* The run's folders on the computer (`computerRunDirs`).
|
||||||
|
*/
|
||||||
|
dirs: ComputerRunDirs;
|
||||||
can_import?: boolean;
|
can_import?: boolean;
|
||||||
}) => `# Files on the computer
|
}) => `# Files on the computer
|
||||||
|
|
||||||
What you make on the computer (downloads, screenshots, a crawl, raw evidence, logs) lives in its home, not in your files. It survives restarts of the computer, but it is not searchable and it goes if the computer is rebuilt.
|
You work only in your own folders on the computer, never elsewhere in it: every shell, code and file tool starts in ${dirs.cwd} (also ~), and a path outside your folders is refused.
|
||||||
|
|
||||||
- The user can open any of it: name a folder on the computer as a folder ref, e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: "seo/coderank/raw" })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
|
- ${dirs.cwd}: this user's work. Everything for them goes here.
|
||||||
|
- ${dirs.bot_dir}: only what is the same for every user you work for (other users' folders in it are not yours).${
|
||||||
|
dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : ""
|
||||||
|
}
|
||||||
|
- The browser downloads to its own folder: move what you download into your folder straight away.
|
||||||
|
|
||||||
|
What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable.
|
||||||
|
|
||||||
|
- The user can open any of it: name a folder on the computer as a folder ref, its path from the computer's workspace (${AppData.ComputerWorkspaceDir}), e.g. ${folderRef({ volume: "computer", owner_id: computer_id, path: `${computerWorkspaceRelative({ absolute: dirs.cwd })}/seo/coderank/raw` })}. It opens that folder in Files, so it downloads nothing. Point at the folder holding the evidence they may want to see.
|
||||||
${
|
${
|
||||||
can_import
|
can_import
|
||||||
? `- import_file copies a file or a folder off the computer into your own files, in one go, and the copy is searchable straight away. Import the finished work (the report, the spreadsheet, the final document), attach it when it is what they asked for, and name it with its [file:ID] ref.\n- The raw evidence can stay on the computer: import it only when the user needs it kept, or when the computer may be rebuilt.\n`
|
? `- import_file copies a file or a folder off the computer into your own files, in one go, and the copy is searchable straight away. Import the finished work (the report, the spreadsheet, the final document), attach it when it is what they asked for, and name it with its [file:ID] ref.\n- The raw evidence can stay on the computer: import it only when the user needs it kept, or when the computer may be rebuilt.\n`
|
||||||
@@ -114,8 +139,10 @@ Every action counts here: saving memory or instructions, creating or changing a
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||||
|
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
||||||
|
* own rather than a worker's.
|
||||||
*/
|
*/
|
||||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
|
export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it and where: a task with no workspace_id runs in that bot's own chat, on its own memory, files and directly assigned computers; one with workspace_id runs in the workspace, on the workspace's context, files and computers as well as the bot's, and lands in that project's conversation. Never swap one for the other without saying so. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: bot_id })} for yourself: the user sees it as the bot's name, linked to its conversation.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt instead while it has no workers,
|
* Added to the coordinator's chat prompt instead while it has no workers,
|
||||||
@@ -123,6 +150,28 @@ export const CoordinatorReplyNote = `As the coordinator, hand work that fits a c
|
|||||||
*/
|
*/
|
||||||
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
|
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a workspace admin's chat prompt in the workspace it leads, while
|
||||||
|
* it has members to hand work to (`delegate_task` is there). `members` are
|
||||||
|
* the bots it directs, written as refs.
|
||||||
|
*/
|
||||||
|
export const workspaceAdminNote = ({
|
||||||
|
workspace_name,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
workspace_name: string;
|
||||||
|
members: { id?: number | "" }[];
|
||||||
|
}) => `# Leading ${workspace_name}
|
||||||
|
|
||||||
|
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself; they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a workspace member's prompt while the workspace has an admin,
|
||||||
|
* so it knows who hands it work there.
|
||||||
|
*/
|
||||||
|
export const workspaceAdminsLine = ({ admins }: { admins: { id?: number | "" }[] }) =>
|
||||||
|
`In this workspace, ${admins.map((a) => botRef({ id: a.id || 0 })).join(" and ")} ${admins.length == 1 ? "is the admin and hands" : "are the admins and hand"} out work: a task from them is part of your job here.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to a bot's prompt when it works through a queued task.
|
* Added to a bot's prompt when it works through a queued task.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const AdminSections = [
|
|||||||
"/admin/workspaces",
|
"/admin/workspaces",
|
||||||
"/admin/routines",
|
"/admin/routines",
|
||||||
"/admin/files",
|
"/admin/files",
|
||||||
|
"/admin/usage",
|
||||||
"/admin/logs",
|
"/admin/logs",
|
||||||
"/admin/help",
|
"/admin/help",
|
||||||
"/admin/settings",
|
"/admin/settings",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ export const CoordinatorBotName = "Coordinator";
|
|||||||
*/
|
*/
|
||||||
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `app_settings` key holding the install's own limit on the tool turns
|
||||||
|
* one chat reply may take. Unset means `AppData.BotReplyMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export const BotReplyMaxToolStepsSettingKey = "bot_reply_max_tool_steps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bot's face is its status. There are no badges or spinners.
|
* A bot's face is its status. There are no badges or spinners.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const JobStepToolLabels: Record<string, string> = {
|
|||||||
update_memory: "Updating memory",
|
update_memory: "Updating memory",
|
||||||
update_instructions: "Updating instructions",
|
update_instructions: "Updating instructions",
|
||||||
load_skill: "Reading a skill",
|
load_skill: "Reading a skill",
|
||||||
|
send_message: "Sending a message",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bot type. A bot with no `bot_types` row is a worker. A type holds
|
||||||
|
* install-wide (`scope: "install"`, its row has no workspace) or in one
|
||||||
|
* workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the
|
||||||
|
* legacy marker of the one coordinator for who sees it; what a bot can do
|
||||||
|
* comes from these.
|
||||||
|
*/
|
||||||
|
export const BotTypes = [
|
||||||
|
{
|
||||||
|
value: "coordinator",
|
||||||
|
title: "Coordinator",
|
||||||
|
description: "Coordinates the whole crew: hands work to every bot, grows the crew, runs workspaces and extensions. One per install, the super admin's alone.",
|
||||||
|
scope: "install",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "admin",
|
||||||
|
title: "Workspace admin",
|
||||||
|
description: "Leads one workspace: hands tasks to the other bots there, sets their instructions there, runs the workspace's task list and keeps its context.",
|
||||||
|
scope: "workspace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "worker",
|
||||||
|
title: "Worker",
|
||||||
|
description: "Does its own job. Every bot without another type.",
|
||||||
|
scope: "install",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a bot type lets a bot do.
|
||||||
|
*/
|
||||||
|
export const BotPermissions = [
|
||||||
|
{
|
||||||
|
value: "delegate_task",
|
||||||
|
title: "Hand tasks to other bots",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_member_instructions",
|
||||||
|
title: "Set other bots' workspace instructions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_workspace_tasks",
|
||||||
|
title: "Run the workspace's task list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "edit_workspace",
|
||||||
|
title: "Edit the workspace's name, description and context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_crew",
|
||||||
|
title: "Create, edit and delete bots, their computers and workspaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_workspaces",
|
||||||
|
title: "Create and delete workspaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manage_extensions",
|
||||||
|
title: "Manage skills, MCP servers and tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_bot_types",
|
||||||
|
title: "Make bots workspace admins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "set_any_picture",
|
||||||
|
title: "Set any bot's picture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "reach_all_tasks",
|
||||||
|
title: "Reach every task on the install",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each bot type may do, where it holds (everywhere for an
|
||||||
|
* install-wide type, in its workspace for a workspace one).
|
||||||
|
*/
|
||||||
|
export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
|
||||||
|
coordinator: BotPermissions.map((bp) => bp.value),
|
||||||
|
admin: [
|
||||||
|
"delegate_task",
|
||||||
|
"set_member_instructions",
|
||||||
|
"manage_workspace_tasks",
|
||||||
|
"edit_workspace",
|
||||||
|
],
|
||||||
|
worker: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which bots a bot may act on (hand a task to, set instructions for), keyed
|
||||||
|
* by the target's type. Same shape as `UserTypeAssignmentMatrix`: the
|
||||||
|
* coordinator passes through. A workspace admin directs workers only, so
|
||||||
|
* two admins never hand work back and forth.
|
||||||
|
*/
|
||||||
|
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: [],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
allowed_actors: ["admin"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may grant a bot type: user types (`admin`; the super admin passes
|
||||||
|
* through) and bot types (`coordinator`). The coordinator type is never
|
||||||
|
* granted: `ensureCoordinatorBot` writes it.
|
||||||
|
*/
|
||||||
|
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
worker: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
|
coordinator: {},
|
||||||
|
admin: {
|
||||||
|
allowed_actors: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
worker: {},
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* A computer is an AIO sandbox running in its own docker container on the
|
* A computer is an AIO sandbox running in its own docker container on the
|
||||||
* cobble network, created by the app (`createComputer`), its home
|
* cobble network, created by the app (`createComputer`), only its
|
||||||
* directory kept under `DATA_DIR/computers/<id>`.
|
* workspace (`AppData.ComputerWorkspaceDir`) kept under
|
||||||
|
* `DATA_DIR/computers/<id>`.
|
||||||
*/
|
*/
|
||||||
export const ComputerKinds = [
|
export const ComputerKinds = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,18 +64,28 @@ export const ReservedToolNames = [
|
|||||||
"create_schedule",
|
"create_schedule",
|
||||||
"create_workspace",
|
"create_workspace",
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
|
"delete_extension",
|
||||||
"delete_schedule",
|
"delete_schedule",
|
||||||
|
"list_extensions",
|
||||||
"list_schedules",
|
"list_schedules",
|
||||||
"load_skill",
|
"load_skill",
|
||||||
|
"propose_custom_tool",
|
||||||
|
"propose_mcp_server",
|
||||||
|
"propose_skill",
|
||||||
"report_run_failed",
|
"report_run_failed",
|
||||||
"search_computers",
|
"search_computers",
|
||||||
|
"send_message",
|
||||||
"set_bot_workspace_instructions",
|
"set_bot_workspace_instructions",
|
||||||
|
"set_extension_access",
|
||||||
"set_picture",
|
"set_picture",
|
||||||
"set_workspace_computer",
|
"set_workspace_computer",
|
||||||
"start_task",
|
"start_task",
|
||||||
|
"update_custom_tool",
|
||||||
"update_instructions",
|
"update_instructions",
|
||||||
|
"update_mcp_server",
|
||||||
"update_memory",
|
"update_memory",
|
||||||
"update_schedule",
|
"update_schedule",
|
||||||
|
"update_skill",
|
||||||
"update_workspace",
|
"update_workspace",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const EnvDict: readonly KnownEnv[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "MAIN_COMPUTER_DATA_DIR",
|
key: "MAIN_COMPUTER_DATA_DIR",
|
||||||
description: `Where the main computer's home (/home/gem) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
|
description: `Where the main computer's workspace (/home/gem/workspace, the only folder bots work in) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/<id>.`,
|
||||||
required: false,
|
required: false,
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Where an MCP server, tool or skill the coordinator proposed stands
|
||||||
|
* (`extension_proposals.status`): `pending` until the super admin
|
||||||
|
* confirms (`confirmed`, saved) or rejects it, `replaced` when the bot
|
||||||
|
* proposed it again, `failed` when confirming couldn't save it. A pending
|
||||||
|
* one past its `expires_at` reads as expired.
|
||||||
|
*/
|
||||||
|
export const ExtensionProposalStatuses = ["pending", "confirmed", "rejected", "replaced", "failed"] as const;
|
||||||
|
|
||||||
|
export type ExtensionProposalStatus = (typeof ExtensionProposalStatuses)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a proposal adds or changes.
|
||||||
|
*/
|
||||||
|
export const ExtensionProposalKinds = [
|
||||||
|
{ value: "mcp", title: "MCP server" },
|
||||||
|
{ value: "tool", title: "tool" },
|
||||||
|
{ value: "skill", title: "skill" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExtensionProposalKind = (typeof ExtensionProposalKinds)[number]["value"];
|
||||||
@@ -53,6 +53,12 @@ export const Harnesses = [
|
|||||||
|
|
||||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `app_settings` key holding the install's own limit on model turns in
|
||||||
|
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export const HarnessMaxToolStepsSettingKey = "harness_max_tool_steps";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vendors preferred for automatic picks, best first. Free models from any
|
* Vendors preferred for automatic picks, best first. Free models from any
|
||||||
* other vendor come after these, and only if they look competent.
|
* other vendor come after these, and only if they look competent.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Activity, CircleHelp, Clock, FolderOpen, type LucideIcon } from "lucide-react";
|
import { Activity, BarChart3, CircleHelp, Clock, FolderOpen, type LucideIcon } from "lucide-react";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import { HelpPath } from "./help-dict";
|
import { HelpPath } from "./help-dict";
|
||||||
|
|
||||||
export type RailPage = {
|
export type RailPage = {
|
||||||
@@ -9,11 +11,16 @@ export type RailPage = {
|
|||||||
* Shown and served to super admins only.
|
* Shown and served to super admins only.
|
||||||
*/
|
*/
|
||||||
super_admin_only?: boolean;
|
super_admin_only?: boolean;
|
||||||
|
/**
|
||||||
|
* Who sees it, when that's an RBAC rule rather than super admins only.
|
||||||
|
*/
|
||||||
|
can_view?: (params: { user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null }) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RoutinesPath = "/admin/routines";
|
export const RoutinesPath = "/admin/routines";
|
||||||
export const FilesPath = "/admin/files";
|
export const FilesPath = "/admin/files";
|
||||||
export const ActivityPath = "/admin/logs";
|
export const ActivityPath = "/admin/logs";
|
||||||
|
export const UsagePath = "/admin/usage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages opened from the rail's user popover that have their own rail: a
|
* Pages opened from the rail's user popover that have their own rail: a
|
||||||
@@ -22,6 +29,7 @@ export const ActivityPath = "/admin/logs";
|
|||||||
export const RailPages: RailPage[] = [
|
export const RailPages: RailPage[] = [
|
||||||
{ title: "Routines", url: RoutinesPath, Icon: Clock },
|
{ title: "Routines", url: RoutinesPath, Icon: Clock },
|
||||||
{ title: "Files", url: FilesPath, Icon: FolderOpen },
|
{ title: "Files", url: FilesPath, Icon: FolderOpen },
|
||||||
|
{ title: "Usage", url: UsagePath, Icon: BarChart3, can_view: canManageCrew },
|
||||||
{ title: "Activity", url: ActivityPath, Icon: Activity, super_admin_only: true },
|
{ title: "Activity", url: ActivityPath, Icon: Activity, super_admin_only: true },
|
||||||
{ title: "Help", url: HelpPath, Icon: CircleHelp },
|
{ title: "Help", url: HelpPath, Icon: CircleHelp },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import { BootId, grabDataBackupRun, startDataBackupRun } from "./data-backup-run";
|
||||||
|
import { isBackupRunning } from "./with-backup-run-lock";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (name: string) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<string>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
describe("startDataBackupRun", () => {
|
||||||
|
test("answers at once, then records the finished archive", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
|
||||||
|
const run = startDataBackupRun({
|
||||||
|
kind: "archive",
|
||||||
|
task: () => task.promise,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.status).toBe("running");
|
||||||
|
expect(run.kind).toBe("archive");
|
||||||
|
expect(run.boot_id).toBe(BootId);
|
||||||
|
expect(isBackupRunning()).toBe(true);
|
||||||
|
|
||||||
|
task.resolve("cobble-data-1.tar.gz");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const done = grabDataBackupRun();
|
||||||
|
expect(done.status).toBe("done");
|
||||||
|
expect(done.name).toBe("cobble-data-1.tar.gz");
|
||||||
|
expect(isBackupRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a second run while one is going, before it starts", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
let second_started = false;
|
||||||
|
|
||||||
|
startDataBackupRun({ kind: "archive", task: () => task.promise });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
startDataBackupRun({
|
||||||
|
kind: "restore",
|
||||||
|
task: async () => {
|
||||||
|
second_started = true;
|
||||||
|
return "x";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow(AppError);
|
||||||
|
expect(second_started).toBe(false);
|
||||||
|
|
||||||
|
task.resolve("cobble-data-2.tar.gz");
|
||||||
|
await settle();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records a failure and frees the lock for the next run", async () => {
|
||||||
|
const task = deferred();
|
||||||
|
|
||||||
|
startDataBackupRun({ kind: "restore", task: () => task.promise });
|
||||||
|
task.reject(new Error("tar: broken"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const failed = grabDataBackupRun();
|
||||||
|
expect(failed.status).toBe("failed");
|
||||||
|
expect(failed.kind).toBe("restore");
|
||||||
|
expect(failed.error).toBe("tar: broken");
|
||||||
|
expect(isBackupRunning()).toBe(false);
|
||||||
|
|
||||||
|
const next = startDataBackupRun({
|
||||||
|
kind: "archive",
|
||||||
|
task: async () => "cobble-data-3.tar.gz",
|
||||||
|
});
|
||||||
|
expect(next.status).toBe("running");
|
||||||
|
await settle();
|
||||||
|
expect(grabDataBackupRun().status).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import type { DataBackupRun, DataBackupRunKind } from "@/src/types";
|
||||||
|
import withBackupRunLock, { isBackupRunning } from "./with-backup-run-lock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes every time the process starts, so the page can tell a restore
|
||||||
|
* restarted the app (the run it started belonged to another boot).
|
||||||
|
*/
|
||||||
|
export const BootId = crypto.randomUUID();
|
||||||
|
|
||||||
|
let run: Omit<DataBackupRun, "boot_id"> | null = null;
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: DataBackupRunKind;
|
||||||
|
/**
|
||||||
|
* The archive or restore itself. It resolves to the archive's name.
|
||||||
|
*/
|
||||||
|
task: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a whole-data archive or restore in the background and hands back
|
||||||
|
* its state at once. Both take far longer than a request may stay silent
|
||||||
|
* (Bun closes an idle socket after 10s, a proxy after about 100s), so the
|
||||||
|
* route answers straight away and the page polls `data-status` until the
|
||||||
|
* run is done or failed. Throws while any backup or restore is running,
|
||||||
|
* before anything starts.
|
||||||
|
*/
|
||||||
|
export function startDataBackupRun({ kind, task }: Params): DataBackupRun {
|
||||||
|
if (isBackupRunning() || run?.status == "running") {
|
||||||
|
throw new AppError(`Another backup or restore is already running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = {
|
||||||
|
kind,
|
||||||
|
status: "running" as const,
|
||||||
|
started_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
run = started;
|
||||||
|
|
||||||
|
withBackupRunLock({ task })
|
||||||
|
.then((name) => {
|
||||||
|
if (run !== started) return;
|
||||||
|
run = { ...started, status: "done", name, finished_at: Date.now() };
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (run !== started) return;
|
||||||
|
run = {
|
||||||
|
...started,
|
||||||
|
status: "failed",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
finished_at: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return grabDataBackupRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last whole-data run this process started, if any, with this boot's
|
||||||
|
* id.
|
||||||
|
*/
|
||||||
|
export function grabDataBackupRun(): DataBackupRun {
|
||||||
|
return { ...(run ?? { status: "idle" }), boot_id: BootId };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
import grabBackupArchiveDir from "@/src/utils/grab-backup-archive-dir";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -18,13 +19,13 @@ const GZIP_MAGIC = [0x1f, 0x8b];
|
|||||||
*/
|
*/
|
||||||
export default async function saveUploadedDataBackup({ file }: Params) {
|
export default async function saveUploadedDataBackup({ file }: Params) {
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
throw new Error(`The uploaded archive is empty`);
|
throw new AppError(`The uploaded archive is empty`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||||
|
|
||||||
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
|
if (head[0] != GZIP_MAGIC[0] || head[1] != GZIP_MAGIC[1]) {
|
||||||
throw new Error(`Only a gzipped tar archive can be restored`);
|
throw new AppError(`Only a gzipped tar archive can be restored`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const archive_dir = grabBackupArchiveDir();
|
const archive_dir = grabBackupArchiveDir();
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ type Params<T> = {
|
|||||||
task: () => Promise<T>;
|
task: () => Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a backup or restore of any kind holds the lock right now, so a
|
||||||
|
* background run can be refused before it starts.
|
||||||
|
*/
|
||||||
|
export function isBackupRunning() {
|
||||||
|
return is_running;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one backup or restore at a time, whichever kind it is, so a
|
* Runs one backup or restore at a time, whichever kind it is, so a
|
||||||
* database snapshot, a database restore and a whole-data archive never
|
* database snapshot, a database restore and a whole-data archive never
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export default async function writeDataBackupArchive(): Promise<CreatedDataBacku
|
|||||||
// after it and an extract drops a single folder.
|
// after it and an extract drops a single folder.
|
||||||
"--transform",
|
"--transform",
|
||||||
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
|
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
|
||||||
|
// A computer's old whole home, moved aside when only its
|
||||||
|
// workspace became the mount (`prepareComputerDataDir`),
|
||||||
|
// is kept on disk but never archived: only workspaces are.
|
||||||
|
// `*` stops at `/`, so a file inside a workspace whose name
|
||||||
|
// happens to hold the suffix is still archived.
|
||||||
|
"--no-wildcards-match-slash",
|
||||||
|
`--exclude=./${AppData.ComputerDataDir}/*${AppData.ComputerOldHomeSuffix}*`,
|
||||||
"-C",
|
"-C",
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
".",
|
".",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ beforeAll(() => {
|
|||||||
(5, 7, NULL, 0),
|
(5, 7, NULL, 0),
|
||||||
(6, 7, NULL, 0),
|
(6, 7, NULL, 0),
|
||||||
(7, 7, NULL, 0),
|
(7, 7, NULL, 0),
|
||||||
(8, 7, NULL, 0)`);
|
(8, 7, NULL, 0),
|
||||||
|
(10, 7, NULL, 0),
|
||||||
|
(11, 7, NULL, 0)`);
|
||||||
|
|
||||||
// 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary)
|
// 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary)
|
||||||
db.run(`INSERT INTO media_paradigms VALUES
|
db.run(`INSERT INTO media_paradigms VALUES
|
||||||
@@ -29,12 +31,14 @@ beforeAll(() => {
|
|||||||
(5, 'bot-image'),
|
(5, 'bot-image'),
|
||||||
(6, 'workspace-image'),
|
(6, 'workspace-image'),
|
||||||
(7, 'workspace-image'),
|
(7, 'workspace-image'),
|
||||||
(8, 'bot-file')`);
|
(8, 'bot-file'),
|
||||||
|
(10, 'bot-attachment')`);
|
||||||
|
|
||||||
// 4: still bot 2's picture, 5: a bot picture no bot points at any more
|
// 4: still bot 2's picture, 5: a bot picture no bot points at any more
|
||||||
db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
|
db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`);
|
||||||
// 6: still workspace 10's picture, 7: a workspace picture nothing points at
|
// 6: still workspace 10's picture, 7: a workspace picture nothing points at
|
||||||
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL)`);
|
// 10: an older picture set straight from a chat upload, still workspace 12's
|
||||||
|
db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL), (12, 10)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function visible({ archived }: { archived?: number } = {}) {
|
function visible({ archived }: { archived?: number } = {}) {
|
||||||
@@ -52,12 +56,12 @@ function visible({ archived }: { archived?: number } = {}) {
|
|||||||
|
|
||||||
describe("sharedPictureMediaSql", () => {
|
describe("sharedPictureMediaSql", () => {
|
||||||
test("every user's current photo, and every bot's and workspace's picture", () => {
|
test("every user's current photo, and every bot's and workspace's picture", () => {
|
||||||
expect(visible()).toEqual([1, 2, 4, 6]);
|
expect(visible()).toEqual([1, 2, 4, 6, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archived media is not a picture anyone gets", () => {
|
test("archived media is not a picture anyone gets", () => {
|
||||||
db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
|
db.run(`UPDATE media SET archived = 1 WHERE id = 2`);
|
||||||
expect(visible({ archived: 0 })).toEqual([1, 4, 6]);
|
expect(visible({ archived: 0 })).toEqual([1, 4, 6, 10]);
|
||||||
db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
|
db.run(`UPDATE media SET archived = 0 WHERE id = 2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +76,11 @@ describe("sharedPictureMediaSql", () => {
|
|||||||
|
|
||||||
test("anything else stays private, a bot's file among them", () => {
|
test("anything else stays private, a bot's file among them", () => {
|
||||||
expect(visible()).not.toContain(8);
|
expect(visible()).not.toContain(8);
|
||||||
|
expect(visible()).not.toContain(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a picture a workspace points at is shared whatever its paradigm", () => {
|
||||||
|
expect(visible()).toContain(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removing a picture row closes it at once", () => {
|
test("removing a picture row closes it at once", () => {
|
||||||
|
|||||||
@@ -13,28 +13,29 @@ type Params = {
|
|||||||
*
|
*
|
||||||
* - a member's current profile photo (their primary `user-profile-image`,
|
* - a member's current profile photo (their primary `user-profile-image`,
|
||||||
* the one `grabUsers` reports as `profile_media_id`),
|
* the one `grabUsers` reports as `profile_media_id`),
|
||||||
* - a bot's picture (`bots.image_media_id`),
|
* - a bot's picture (whatever `bots.image_media_id` points at),
|
||||||
* - a workspace's picture (`workspaces.image_media_id`).
|
* - a workspace's picture (whatever `workspaces.image_media_id` points at).
|
||||||
*
|
*
|
||||||
* Bots and workspaces belong to the install, and people know each other by
|
* Bots and workspaces belong to the install, and people know each other by
|
||||||
* their photo, so a picture is never limited to whoever happens to reach
|
* their photo, so a picture is never limited to whoever happens to reach
|
||||||
* its owner. It does have to be one still in use: the paradigm alone is not
|
* its owner. It does have to be one still in use: a profile photo's
|
||||||
* enough, since a removed picture leaves its paradigm row behind.
|
* paradigm alone is not enough, since a removed photo leaves its paradigm
|
||||||
|
* row behind. A bot's or workspace's picture is shared because the bot or
|
||||||
|
* workspace points at it, not by its paradigm: a picture set before
|
||||||
|
* pictures were copied into their own `bot-image`/`workspace-image` row is
|
||||||
|
* the chat upload itself, and it is still the face everyone sees.
|
||||||
*/
|
*/
|
||||||
export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
|
export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition {
|
||||||
return {
|
return {
|
||||||
sql: `EXISTS (
|
sql: `(
|
||||||
|
EXISTS (
|
||||||
SELECT 1 FROM media_paradigms mp
|
SELECT 1 FROM media_paradigms mp
|
||||||
WHERE mp.media_id = ${alias}.id
|
WHERE mp.media_id = ${alias}.id
|
||||||
AND (
|
AND mp.media_paradigm = 'user-profile-image'
|
||||||
(mp.media_paradigm = 'user-profile-image' AND ${alias}.is_primary = 1)
|
AND ${alias}.is_primary = 1
|
||||||
OR (mp.media_paradigm = 'bot-image' AND EXISTS (
|
|
||||||
SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id
|
|
||||||
))
|
|
||||||
OR (mp.media_paradigm = 'workspace-image' AND EXISTS (
|
|
||||||
SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id
|
|
||||||
))
|
|
||||||
)
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id)
|
||||||
)`,
|
)`,
|
||||||
values: [],
|
values: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { BotReplyMaxToolStepsSettingKey } from "@/src/dict/bot-dict";
|
||||||
|
import { HarnessMaxToolStepsSettingKey } from "@/src/dict/harness-dict";
|
||||||
|
import readStepLimit from "@/src/utils/read-step-limit";
|
||||||
|
import grabAppSetting from "./grab-app-setting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model turns a task run may take: the install's own limit
|
||||||
|
* (`harness_max_tool_steps`, Settings > General), else
|
||||||
|
* `AppData.HarnessMaxToolSteps`. `runTextLoop`'s default is the code's one,
|
||||||
|
* so a task run passes this in.
|
||||||
|
*/
|
||||||
|
export async function grabHarnessMaxToolSteps() {
|
||||||
|
return readStepLimit({
|
||||||
|
saved: await grabAppSetting({ key: HarnessMaxToolStepsSettingKey }),
|
||||||
|
fallback: AppData.HarnessMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model turns a chat reply may take: the install's own limit
|
||||||
|
* (`bot_reply_max_tool_steps`, Settings > General), else
|
||||||
|
* `AppData.BotReplyMaxToolSteps`.
|
||||||
|
*/
|
||||||
|
export async function grabReplyMaxToolSteps() {
|
||||||
|
return readStepLimit({
|
||||||
|
saved: await grabAppSetting({ key: BotReplyMaxToolStepsSettingKey }),
|
||||||
|
fallback: AppData.BotReplyMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both limits with their defaults, for the settings page.
|
||||||
|
*/
|
||||||
|
export default async function grabRunLimitsOverview() {
|
||||||
|
const [task_steps, reply_steps] = await Promise.all([
|
||||||
|
grabHarnessMaxToolSteps(),
|
||||||
|
grabReplyMaxToolSteps(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overview: RunLimitsOverview = {
|
||||||
|
task_steps,
|
||||||
|
reply_steps,
|
||||||
|
task_default: AppData.HarnessMaxToolSteps,
|
||||||
|
reply_default: AppData.BotReplyMaxToolSteps,
|
||||||
|
max: AppData.BotStepLimitMax,
|
||||||
|
};
|
||||||
|
|
||||||
|
return overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunLimitsOverview = {
|
||||||
|
/**
|
||||||
|
* The limit a task run uses right now.
|
||||||
|
*/
|
||||||
|
task_steps: number;
|
||||||
|
/**
|
||||||
|
* The limit a chat reply uses right now.
|
||||||
|
*/
|
||||||
|
reply_steps: number;
|
||||||
|
/**
|
||||||
|
* What a task run uses when the install saved no limit.
|
||||||
|
*/
|
||||||
|
task_default: number;
|
||||||
|
/**
|
||||||
|
* What a chat reply uses when the install saved no limit.
|
||||||
|
*/
|
||||||
|
reply_default: number;
|
||||||
|
/**
|
||||||
|
* The most the settings page accepts.
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import type { StorageScope } from "@/src/utils/storage-access";
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
import { canReachScope } from "@/src/utils/storage-access";
|
import { canReachScope } from "@/src/utils/storage-access";
|
||||||
import grabBotFile from "./grab-bot-file";
|
import grabBotFile from "./grab-bot-file";
|
||||||
@@ -42,12 +42,13 @@ export default async function copyBotFile({
|
|||||||
|
|
||||||
const file = await grabBotFile({ scopes, file_id });
|
const file = await grabBotFile({ scopes, file_id });
|
||||||
|
|
||||||
if (!file?.id || !file.media?.media_write_path) {
|
const absolute = mediaFilePath({ write_path: file?.media?.media_write_path });
|
||||||
|
|
||||||
|
if (!file?.id || !absolute) {
|
||||||
throw new AppError(`That file isn't in your files.`);
|
throw new AppError(`That file isn't in your files.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
const source = await Bun.file(absolute).arrayBuffer();
|
||||||
const source = await Bun.file(`${DATA_DIR}/${file.media.media_write_path}`).arrayBuffer();
|
|
||||||
|
|
||||||
return await recordBotFile({
|
return await recordBotFile({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
import { rmSync } from "node:fs";
|
import { rmSync } from "node:fs";
|
||||||
@@ -24,7 +25,6 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
|
|||||||
throw new AppError(`That file isn't in your files.`);
|
throw new AppError(`That file isn't in your files.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
const root = grabStorageDir({ scope: file.scope as any, owner_id: file.owner_id! });
|
||||||
const absolute = path.join(root, file.path || "");
|
const absolute = path.join(root, file.path || "");
|
||||||
|
|
||||||
@@ -55,12 +55,10 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
|
|||||||
values: [file.media_id],
|
values: [file.media_id],
|
||||||
}).then((res) => res.payload?.[0]);
|
}).then((res) => res.payload?.[0]);
|
||||||
|
|
||||||
if (media?.media_write_path) {
|
for (const write_path of [media?.media_write_path, media?.media_thumbnail_write_path]) {
|
||||||
rmSync(path.join(DATA_DIR, media.media_write_path), { force: true });
|
const absolute = mediaFilePath({ write_path });
|
||||||
}
|
|
||||||
|
|
||||||
if (media?.media_thumbnail_write_path) {
|
if (absolute) rmSync(absolute, { force: true });
|
||||||
rmSync(path.join(DATA_DIR, media.media_thumbnail_write_path), { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
|
await BunSQLite.sql({ sql: `DELETE FROM media_paradigms WHERE media_id = ?`, values: [file.media_id] });
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import embedText from "@/src/harness/embeddings/embed-text";
|
import embedText from "@/src/harness/embeddings/embed-text";
|
||||||
import HarnessError from "@/src/harness/harness-error";
|
import HarnessError from "@/src/harness/harness-error";
|
||||||
|
import { grabHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||||
|
import type { HarnessUsageScope } from "@/src/harness/usage/types";
|
||||||
|
import runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
|
||||||
|
import grabUsageOwnerUserId from "@/src/functions/backend/db/usage/grab-usage-owner";
|
||||||
import chunkText from "./chunk-text";
|
import chunkText from "./chunk-text";
|
||||||
import extractBotFileText from "./extract-bot-file-text";
|
import extractBotFileText from "./extract-bot-file-text";
|
||||||
|
|
||||||
@@ -72,13 +76,27 @@ export default async function ingestBotFile({
|
|||||||
return { status: "skipped", chunks: 0 };
|
return { status: "skipped", chunks: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's own run already carries its user. The ingest queue has no run
|
||||||
|
* behind it, so everything this file costs - reading an image or an
|
||||||
|
* audio file, embedding its chunks - is entered under the owner's scope
|
||||||
|
* instead: it spends real tokens and must show on the usage page.
|
||||||
|
*/
|
||||||
|
const owner_scope: HarnessUsageScope | null = grabHarnessUsageScope()?.user_id
|
||||||
|
? null
|
||||||
|
: await resolveOwnerScope({ file });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await extractBotFileText({
|
const text = await runWithFallbackScope({
|
||||||
|
scope: owner_scope,
|
||||||
|
task: () =>
|
||||||
|
extractBotFileText({
|
||||||
write_path: media?.media_write_path,
|
write_path: media?.media_write_path,
|
||||||
text_content: media?.text_content,
|
text_content: media?.text_content,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
mime_type: file.mime_type,
|
mime_type: file.mime_type,
|
||||||
read_media,
|
read_media,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await clearChunks({ file_id: file.id });
|
await clearChunks({ file_id: file.id });
|
||||||
@@ -107,11 +125,11 @@ export default async function ingestBotFile({
|
|||||||
*/
|
*/
|
||||||
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
|
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
|
||||||
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
|
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
|
||||||
const { vectors } = await embedText({
|
const { vectors } = await runEmbed({
|
||||||
input: batch,
|
scope: owner_scope,
|
||||||
bot_id: file.bot_id!,
|
|
||||||
workspace_id: file.scope == "workspace" ? file.owner_id : null,
|
|
||||||
signal,
|
signal,
|
||||||
|
file,
|
||||||
|
batch,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < vectors.length; i++) {
|
for (let i = 0; i < vectors.length; i++) {
|
||||||
@@ -252,3 +270,54 @@ async function markIngested({
|
|||||||
values: [chunks > 0 ? 1 : 0, now, file_id],
|
values: [chunks > 0 ? 1 : 0, now, file_id],
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One batch of a file's chunks, embedded under whatever attribution owns
|
||||||
|
* it: the run that asked for the ingest, else the file's owner.
|
||||||
|
*/
|
||||||
|
async function runEmbed({
|
||||||
|
scope,
|
||||||
|
signal,
|
||||||
|
file,
|
||||||
|
batch,
|
||||||
|
}: {
|
||||||
|
scope: HarnessUsageScope | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
file: BUN_SQLITE_COBBLE_BOT_FILES;
|
||||||
|
batch: string[];
|
||||||
|
}) {
|
||||||
|
return await runWithFallbackScope({
|
||||||
|
scope,
|
||||||
|
task: () =>
|
||||||
|
embedText({
|
||||||
|
input: batch,
|
||||||
|
bot_id: file.bot_id!,
|
||||||
|
workspace_id: file.scope == "workspace" ? file.owner_id : null,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attribution a file the queue ingests reports under: its workspace's
|
||||||
|
* creator when it shares one, else its bot's.
|
||||||
|
*/
|
||||||
|
async function resolveOwnerScope({
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: BUN_SQLITE_COBBLE_BOT_FILES;
|
||||||
|
}): Promise<HarnessUsageScope | null> {
|
||||||
|
const workspace_id = file.scope == "workspace" ? file.owner_id : null;
|
||||||
|
const user_id = await grabUsageOwnerUserId({ bot_id: file.bot_id, workspace_id });
|
||||||
|
|
||||||
|
if (!user_id) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id,
|
||||||
|
bot_id: file.scope == "bot" ? file.owner_id : file.bot_id,
|
||||||
|
workspace_id,
|
||||||
|
session_id: null,
|
||||||
|
message_id: null,
|
||||||
|
job_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
|
import { BotUploadFolder } from "@/src/dict/bot-file-dict";
|
||||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||||
import { logError } from "@/src/functions/backend/logger";
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import recordBotFile from "./record-bot-file";
|
import recordBotFile from "./record-bot-file";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -26,14 +26,15 @@ export default async function storeChatAttachments({
|
|||||||
}: Params) {
|
}: Params) {
|
||||||
if (!media.length) return;
|
if (!media.length) return;
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
|
const folder = `${BotUploadFolder}/${new Date().toISOString().slice(0, 7)}`;
|
||||||
|
|
||||||
for (const file of media) {
|
for (const file of media) {
|
||||||
try {
|
try {
|
||||||
if (!file.media_write_path) continue;
|
const absolute = mediaFilePath({ write_path: file.media_write_path });
|
||||||
|
|
||||||
const source = Bun.file(`${DATA_DIR}/${file.media_write_path}`);
|
if (!absolute) continue;
|
||||||
|
|
||||||
|
const source = Bun.file(absolute);
|
||||||
|
|
||||||
if (!(await source.exists())) continue;
|
if (!(await source.exists())) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { ConversationHistory } from "@/src/types";
|
||||||
|
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||||
|
import grabBotMessages from "../bots/grab-bot-messages";
|
||||||
|
import ensureBotSession from "./ensure-bot-session";
|
||||||
|
import grabUserConversation from "./grab-user-conversation";
|
||||||
|
import markBotSessionRead from "./mark-bot-session-read";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the conversation is in; none for the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's history, which the conversation pages load on the
|
||||||
|
* client once they render (`api/admin/bots/conversation-history`): the
|
||||||
|
* bot's active session there (marked read), its latest
|
||||||
|
* `AppData.ConversationFirstMessages` messages (the thread loads older
|
||||||
|
* ones as it scrolls) and the bot's active task in it. Everything else on
|
||||||
|
* the page comes with it from the server (`grabConversationProps`).
|
||||||
|
* Null when the user can't chat with the bot there.
|
||||||
|
*/
|
||||||
|
export default async function grabConversationHistory({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<ConversationHistory | null> {
|
||||||
|
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation?.bot?.id) return null;
|
||||||
|
|
||||||
|
const open_bot_id = conversation.bot.id;
|
||||||
|
const session = await ensureBotSession({
|
||||||
|
user_id,
|
||||||
|
bot_id: open_bot_id,
|
||||||
|
workspace_id: conversation.workspace_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.unread) {
|
||||||
|
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||||
|
session.unread = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [messages, task] = await Promise.all([
|
||||||
|
grabBotMessages({
|
||||||
|
session_id: session.id!,
|
||||||
|
user_id,
|
||||||
|
limit: AppData.ConversationFirstMessages + 1,
|
||||||
|
}),
|
||||||
|
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const has_more = messages.length > AppData.ConversationFirstMessages;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
messages: has_more ? messages.slice(1) : messages,
|
||||||
|
has_more,
|
||||||
|
task,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
|
||||||
import type { BotTextModel, PagePropsType } from "@/src/types";
|
import type { BotTextModel, PagePropsType } from "@/src/types";
|
||||||
import railBots from "@/src/utils/rail-bots";
|
import railBots from "@/src/utils/rail-bots";
|
||||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||||
import grabBots from "../bots/grab-bots";
|
import grabBots from "../bots/grab-bots";
|
||||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
|
||||||
import grabBotMessages from "../bots/grab-bot-messages";
|
|
||||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||||
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||||
import ensureBotSession from "./ensure-bot-session";
|
|
||||||
import grabUserConversation from "./grab-user-conversation";
|
import grabUserConversation from "./grab-user-conversation";
|
||||||
import markBotSessionRead from "./mark-bot-session-read";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -27,11 +22,12 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* The page props of a conversation (`/admin/bots/<id>` or
|
* The page props of a conversation (`/admin/bots/<id>` or
|
||||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||||
* in a workspace) and workspaces, the bot, its active session (marked
|
* in a workspace) and workspaces, the bot, the text model it runs on, its
|
||||||
* read) and its latest `AppData.ConversationFirstMessages` messages (the
|
* computers, tools, MCP servers and files there, and whether its mic and
|
||||||
* thread loads older ones as it scrolls), the text model it runs on, its computers,
|
* read-aloud have a model to run on. The history (its session, messages
|
||||||
* tools and MCP servers and active task there, and whether its mic and
|
* and active task) is left to the client (`grabConversationHistory`,
|
||||||
* read-aloud have a model to run on. The full model settings are left to
|
* `api/admin/bots/conversation-history`), so a page the back button
|
||||||
|
* restores never shows an old thread. The full model settings are left to
|
||||||
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||||
* and the conversation's small setting
|
* and the conversation's small setting
|
||||||
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
||||||
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
|
|||||||
if (!conversation) return null;
|
if (!conversation) return null;
|
||||||
|
|
||||||
const scope = conversation.workspace_id;
|
const scope = conversation.workspace_id;
|
||||||
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
|
|
||||||
|
|
||||||
if (session.unread) {
|
|
||||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
|
||||||
session.unread = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [bots, workspaces] = await Promise.all([
|
const [bots, workspaces] = await Promise.all([
|
||||||
grabBots({ user_id, workspace_id: scope }),
|
grabBots({ user_id, workspace_id: scope }),
|
||||||
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
|
|||||||
|
|
||||||
if (!bot?.id) return null;
|
if (!bot?.id) return null;
|
||||||
|
|
||||||
|
/** Loading its history marks the open session read. */
|
||||||
bot.unread = 0;
|
bot.unread = 0;
|
||||||
|
|
||||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||||
grabBotMessages({
|
|
||||||
session_id: session.id!,
|
|
||||||
user_id,
|
|
||||||
limit: AppData.ConversationFirstMessages + 1,
|
|
||||||
}),
|
|
||||||
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
|
||||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||||
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
||||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bots: railBots({ bots }),
|
bots: railBots({ bots }),
|
||||||
bot: railBots({ bots: [bot] })[0],
|
bot: railBots({ bots: [bot] })[0],
|
||||||
workspaces: railWorkspaces({ workspaces }),
|
workspaces: railWorkspaces({ workspaces }),
|
||||||
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
|
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
|
||||||
bot_session: session,
|
|
||||||
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
|
|
||||||
bot_messages_more,
|
|
||||||
bot_text_model,
|
bot_text_model,
|
||||||
bot_computers,
|
bot_computers,
|
||||||
bot_task,
|
|
||||||
bot_extensions,
|
bot_extensions,
|
||||||
bot_files,
|
bot_files,
|
||||||
bot_voice: { transcription, speech },
|
bot_voice: { transcription, speech },
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { BotTypes } from "@/src/dict/bot-types-dict";
|
||||||
|
import type { BotType } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import botTypeAssignment from "@/src/utils/rbac/bot-type-assignment";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabUserTypes from "../users/grab-user-types";
|
||||||
|
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
|
||||||
|
export type BotTypeChangeParams = {
|
||||||
|
/**
|
||||||
|
* The user making the change, or the user a granting bot runs for.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot making the change from chat (the coordinator), if any: it
|
||||||
|
* must be allowed as well as the user.
|
||||||
|
*/
|
||||||
|
actor_bot_id?: number | string | null;
|
||||||
|
bot_id: number | string;
|
||||||
|
bot_type: BotType;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a grant or removal of a bot type: the user (and the bot, when one
|
||||||
|
* acts) may do it (`botTypeAssignment`), only workspace types are ever
|
||||||
|
* changed (the coordinator's is written by `ensureCoordinatorBot`), and the
|
||||||
|
* bot works in the workspace. Returns the bot and the workspace id.
|
||||||
|
*/
|
||||||
|
export default async function assertBotTypeChange({
|
||||||
|
user_id,
|
||||||
|
actor_bot_id,
|
||||||
|
bot_id,
|
||||||
|
bot_type,
|
||||||
|
workspace_id,
|
||||||
|
action,
|
||||||
|
}: BotTypeChangeParams & { action: "assign" | "delete" }) {
|
||||||
|
const type = BotTypes.find((bt) => bt.value == bot_type);
|
||||||
|
|
||||||
|
if (!type || type.scope != "workspace") {
|
||||||
|
throw new AppError(`Only a workspace type can be given or taken away.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspace_id) {
|
||||||
|
throw new AppError(`Pick the workspace the bot is ${type.title.toLowerCase()} of.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user_types = await grabUserTypes({ user_id });
|
||||||
|
|
||||||
|
if (!botTypeAssignment({ actor_user_types: user_types, target_bot_type: bot_type, action })) {
|
||||||
|
throw new AppError(`Only an admin can do that.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor_bot_id) {
|
||||||
|
const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id });
|
||||||
|
|
||||||
|
if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) {
|
||||||
|
throw new AppError(`Only the coordinator can do that.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||||
|
|
||||||
|
if (!bot?.id || !workspace?.id) {
|
||||||
|
throw new AppError(`Bot or workspace not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot.kind == "coordinator") {
|
||||||
|
throw new AppError(`The coordinator already leads every workspace.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "assign") {
|
||||||
|
const member = await BunSQLite.sql<{ n: number }>({
|
||||||
|
sql: `SELECT COUNT(*) AS n FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
|
values: [workspace.id, bot.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member.payload?.[0]?.n) {
|
||||||
|
throw new AppError(`${bot.name} isn't in ${workspace.name}. Add it to the workspace first.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bot, workspace };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives the coordinator its install-wide `coordinator` bot type, once.
|
||||||
|
* The only place that type is ever written.
|
||||||
|
*/
|
||||||
|
export default async function ensureCoordinatorType({ bot_id }: Params) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, created_at, updated_at)
|
||||||
|
SELECT ?, 'coordinator', NULL, ?, ? WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM bot_types WHERE bot_id = ? AND bot_type = 'coordinator' AND workspace_id IS NULL
|
||||||
|
)`,
|
||||||
|
values: [Number(bot_id), Date.now(), Date.now(), Number(bot_id)],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { BotPermission } from "@/src/types";
|
||||||
|
import botCan from "@/src/utils/rbac/bot-can";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Where the run works: its own chat (null) or a workspace.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotRunPowers = {
|
||||||
|
bot_types: BotTypeRow[];
|
||||||
|
/**
|
||||||
|
* Holds the install-wide coordinator type.
|
||||||
|
*/
|
||||||
|
is_coordinator: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces it's admin of.
|
||||||
|
*/
|
||||||
|
admin_workspace_ids: number[];
|
||||||
|
/**
|
||||||
|
* Admin of the workspace the run is in.
|
||||||
|
*/
|
||||||
|
is_workspace_admin: boolean;
|
||||||
|
/**
|
||||||
|
* Whether its types allow `permission` where the run works. The bot's
|
||||||
|
* side only: the run's user must still be allowed.
|
||||||
|
*/
|
||||||
|
can: (permission: BotPermission) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a bot's types let it do on one run, read once (`grabBotTypes`).
|
||||||
|
*/
|
||||||
|
export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise<BotRunPowers> {
|
||||||
|
const bot_types = await grabBotTypes({ bot_id });
|
||||||
|
|
||||||
|
const admin_workspace_ids = bot_types
|
||||||
|
.filter((bt) => bt.bot_type == "admin" && bt.workspace_id)
|
||||||
|
.map((bt) => Number(bt.workspace_id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot_types,
|
||||||
|
is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id),
|
||||||
|
admin_workspace_ids,
|
||||||
|
is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))),
|
||||||
|
can: (permission) => botCan({ bot_types, permission, workspace_id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's types (`bot_types`), plus the install-wide coordinator type for
|
||||||
|
* the legacy `kind = 'coordinator'` bot, so the coordinator keeps its
|
||||||
|
* powers before `ensureCoordinatorBot` has written its row.
|
||||||
|
*/
|
||||||
|
export default async function grabBotTypes({ bot_id }: Params): Promise<BotTypeRow[]> {
|
||||||
|
const res = await BunSQLite.sql<BotTypeRow>({
|
||||||
|
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ?
|
||||||
|
UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`,
|
||||||
|
values: [Number(bot_id), Number(bot_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_ids: (number | string | "" | undefined)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Row = BotTypeRow & { bot_id: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `grabBotTypes` for a list of bots at once, keyed by bot id. A bot with no
|
||||||
|
* row is missing from the map (a worker).
|
||||||
|
*/
|
||||||
|
export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<number, BotTypeRow[]>> {
|
||||||
|
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
|
||||||
|
const map = new Map<number, BotTypeRow[]>();
|
||||||
|
|
||||||
|
if (!ids.length) return map;
|
||||||
|
|
||||||
|
const marks = ids.map(() => "?").join(", ");
|
||||||
|
const res = await BunSQLite.sql<Row>({
|
||||||
|
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks})
|
||||||
|
UNION SELECT id AS bot_id, 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id IN (${marks}) AND kind = 'coordinator'`,
|
||||||
|
values: [...ids, ...ids],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of res.payload || []) {
|
||||||
|
const list = map.get(row.bot_id) || [];
|
||||||
|
list.push({ bot_type: row.bot_type, workspace_id: row.workspace_id });
|
||||||
|
map.set(row.bot_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import botCanTarget from "@/src/utils/rbac/bot-can-target";
|
||||||
|
import grabCrewWorkers from "../bots/grab-crew-workers";
|
||||||
|
import grabBotTypes from "./grab-bot-types";
|
||||||
|
import grabBotsTypes from "./grab-bots-types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The bot handing out the work.
|
||||||
|
*/
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots in a workspace that a bot may direct there (hand a task to, set
|
||||||
|
* instructions for): read fresh, so a member added mid-run is there, and
|
||||||
|
* filtered through `BotTypeTargetMatrix` (`botCanTarget`), never itself.
|
||||||
|
*/
|
||||||
|
export default async function grabDirectableBots({ bot_id, workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
|
const members = (await grabCrewWorkers({ workspace_id })).filter((bot) => bot.id != Number(bot_id));
|
||||||
|
|
||||||
|
if (!members.length) return [];
|
||||||
|
|
||||||
|
const actor_bot_types = await grabBotTypes({ bot_id });
|
||||||
|
const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) });
|
||||||
|
|
||||||
|
return members.filter((bot) =>
|
||||||
|
botCanTarget({
|
||||||
|
actor_bot_types,
|
||||||
|
target_bot_types: types.get(Number(bot.id)) || [],
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots that are admins of a workspace and still work in it.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT b.* FROM bots b
|
||||||
|
JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?
|
||||||
|
JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = bt.workspace_id
|
||||||
|
GROUP BY b.id ORDER BY b.id ASC`,
|
||||||
|
values: [Number(workspace_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a workspace type away from a bot. Taking one it doesn't have is a
|
||||||
|
* no-op. Checked by `assertBotTypeChange`.
|
||||||
|
*/
|
||||||
|
export default async function removeBotType(params: BotTypeChangeParams) {
|
||||||
|
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "delete" });
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ?`,
|
||||||
|
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't remove the bot's type: ${res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Bot type removed`,
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
plain_text: `${bot.name} (#${bot.id}) is no longer ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return { bot, workspace };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a bot a workspace type (makes it admin of a workspace it works
|
||||||
|
* in). Giving it again is a no-op. Checked by `assertBotTypeChange`.
|
||||||
|
*/
|
||||||
|
export default async function setBotType(params: BotTypeChangeParams) {
|
||||||
|
const { bot, workspace } = await assertBotTypeChange({ ...params, action: "assign" });
|
||||||
|
|
||||||
|
const existing = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `SELECT id FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ? LIMIT 1`,
|
||||||
|
values: [Number(bot.id), params.bot_type, Number(workspace.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.payload?.[0]?.id) return { bot, workspace, changed: false };
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, granted_by_user_id, granted_by_bot_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
values: [
|
||||||
|
Number(bot.id),
|
||||||
|
params.bot_type,
|
||||||
|
Number(workspace.id),
|
||||||
|
Number(params.user_id),
|
||||||
|
params.actor_bot_id ? Number(params.actor_bot_id) : null,
|
||||||
|
Date.now(),
|
||||||
|
Date.now(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't save the bot's type: ${res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Bot type given`,
|
||||||
|
category: "system",
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
plain_text: `${bot.name} (#${bot.id}) is now ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return { bot, workspace, changed: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import answeredMessageIdSql from "./answered-message-id-sql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What decides whether a queued reply still has something to answer, run
|
||||||
|
* against real SQLite: only finished replies in the same session count, so
|
||||||
|
* a failed or still running one never hides a message.
|
||||||
|
*/
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, session_id INTEGER, kind TEXT, status TEXT, message_id INTEGER)`,
|
||||||
|
);
|
||||||
|
db.run(`INSERT INTO bots_queue VALUES
|
||||||
|
(1, 100, 'reply', 'completed', 10),
|
||||||
|
(2, 100, 'reply', 'completed', 14),
|
||||||
|
(3, 100, 'reply', 'processing', 20),
|
||||||
|
(4, 100, 'reply', 'failed', 22),
|
||||||
|
(5, 100, 'task', 'completed', 30),
|
||||||
|
(6, 101, 'reply', 'completed', 40),
|
||||||
|
(7, 102, 'reply', 'pending', 50)`);
|
||||||
|
|
||||||
|
function answered(session_id: number) {
|
||||||
|
const { sql, values } = answeredMessageIdSql({ session_id });
|
||||||
|
|
||||||
|
return (db.query(sql).get(...values) as { id: number | null }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("answeredMessageIdSql", () => {
|
||||||
|
test("is the newest message a finished reply in the session read", () => {
|
||||||
|
expect(answered(100)).toBe(14);
|
||||||
|
expect(answered(101)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores running and failed replies, and tasks", () => {
|
||||||
|
expect(answered(100)).not.toBe(20);
|
||||||
|
expect(answered(100)).not.toBe(22);
|
||||||
|
expect(answered(100)).not.toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is null where no reply has finished", () => {
|
||||||
|
expect(answered(102)).toBe(null);
|
||||||
|
expect(answered(999)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SqlCondition } from "@/src/utils/crew-access/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQL for the newest user message a finished reply in the session
|
||||||
|
* answered: a reply job's `message_id` is the newest one it read
|
||||||
|
* (`markReplyAnswered`), so a message with a higher id came in after the
|
||||||
|
* bot last looked and still needs an answer, wherever its reply landed in
|
||||||
|
* the thread. Selects one `id` column, null when no reply has finished.
|
||||||
|
*/
|
||||||
|
export default function answeredMessageIdSql({ session_id }: Params): SqlCondition {
|
||||||
|
return {
|
||||||
|
sql: `SELECT MAX(message_id) AS id FROM bots_queue
|
||||||
|
WHERE session_id = ? AND kind = 'reply' AND status = 'completed'`,
|
||||||
|
values: [Number(session_id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import relayJobToRequester from "./relay-job-to-requester";
|
|||||||
import jobSessionId from "./job-session-id";
|
import jobSessionId from "./job-session-id";
|
||||||
import grabJobUserId from "./grab-job-user-id";
|
import grabJobUserId from "./grab-job-user-id";
|
||||||
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
import grabRunningBotJobFace from "./grab-running-bot-job-face";
|
||||||
|
import withMessageUsage from "../usage/with-message-usage";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
job_id: number | string;
|
job_id: number | string;
|
||||||
@@ -78,7 +79,7 @@ export default async function completeBotJob({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = silent
|
const inserted = silent
|
||||||
? null
|
? null
|
||||||
: await insertBotMessage({
|
: await insertBotMessage({
|
||||||
bot_id: job.bot_id,
|
bot_id: job.bot_id,
|
||||||
@@ -89,6 +90,26 @@ export default async function completeBotJob({
|
|||||||
media_ids: attachments,
|
media_ids: attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (job.id && inserted?.id) {
|
||||||
|
/**
|
||||||
|
* The job's model calls were written down as they happened, before
|
||||||
|
* its message existed. Now that it does, they belong to it, so the
|
||||||
|
* thread can show what the answer cost.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE token_usages SET message_id = ? WHERE job_id = ? AND message_id IS NULL`,
|
||||||
|
values: [inserted.id, job.id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushed live as it is returned, so it carries its usage now: the
|
||||||
|
* thread shows the tokens without a reload.
|
||||||
|
*/
|
||||||
|
const message = inserted
|
||||||
|
? ((await withMessageUsage({ messages: [inserted] }).catch(() => [inserted]))[0] ?? inserted)
|
||||||
|
: null;
|
||||||
|
|
||||||
const user_id = await grabJobUserId({ job });
|
const user_id = await grabJobUserId({ job });
|
||||||
|
|
||||||
const bot = !user_id
|
const bot = !user_id
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import answeredMessageIdSql from "./answered-message-id-sql";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest user message a finished reply in the session answered
|
||||||
|
* (`answeredMessageIdSql`), or null when none is on record.
|
||||||
|
*/
|
||||||
|
export default async function grabAnsweredMessageId({ session_id }: Params) {
|
||||||
|
const { sql, values } = answeredMessageIdSql({ session_id });
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<{ id: number | null }>({ sql, values });
|
||||||
|
const id = res.payload?.[0]?.id;
|
||||||
|
|
||||||
|
return id ? Number(id) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
job_id: number | string;
|
||||||
|
/**
|
||||||
|
* The newest user message the reply read.
|
||||||
|
*/
|
||||||
|
message_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the newest user message a running reply read, so a reply queued
|
||||||
|
* for a message it already took in finishes silently and one it missed is
|
||||||
|
* still answered (`grabAnsweredMessageId`). Never lowers it.
|
||||||
|
*/
|
||||||
|
export default async function markReplyAnswered({ job_id, message_id }: Params) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bots_queue SET message_id = ?
|
||||||
|
WHERE id = ? AND status = 'processing' AND (message_id IS NULL OR message_id < ?)`,
|
||||||
|
values: [message_id, job_id, message_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
);
|
);
|
||||||
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly, since foreign keys are off outside the server process.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
|||||||
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
||||||
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
||||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||||
|
import ensureCoordinatorType from "../bot-types/ensure-coordinator-type";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +17,9 @@ type Params = {
|
|||||||
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
||||||
* if there is none. A
|
* if there is none. A
|
||||||
* coordinator without a prompt gets the default; an edited prompt is never
|
* coordinator without a prompt gets the default; an edited prompt is never
|
||||||
* overwritten. Safe to call on every visit.
|
* overwritten. Its install-wide `coordinator` bot type is written too
|
||||||
|
* (`ensureCoordinatorType`), which backfills older installs. Safe to call
|
||||||
|
* on every visit.
|
||||||
*/
|
*/
|
||||||
export default async function ensureCoordinatorBot({ user_id }: Params) {
|
export default async function ensureCoordinatorBot({ user_id }: Params) {
|
||||||
const existing = (
|
const existing = (
|
||||||
@@ -43,6 +46,8 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureCoordinatorType({ bot_id: existing.id });
|
||||||
|
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await attachDefaultExtensions({ bot_id });
|
await attachDefaultExtensions({ bot_id });
|
||||||
|
await ensureCoordinatorType({ bot_id });
|
||||||
|
|
||||||
return bot_id;
|
return bot_id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||||
import type { BotMessageWithAttachments } from "@/src/types";
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import grabMessageUsage from "../usage/grab-message-usage";
|
||||||
import grabMessagesMedia from "./grab-messages-media";
|
import grabMessagesMedia from "./grab-messages-media";
|
||||||
import grabMessagesReactions from "./grab-messages-reactions";
|
import grabMessagesReactions from "./grab-messages-reactions";
|
||||||
import grabMessagesReplies from "./grab-messages-replies";
|
import grabMessagesReplies from "./grab-messages-replies";
|
||||||
@@ -13,6 +14,15 @@ type Params = {
|
|||||||
* Only messages older than this one, for loading further back.
|
* Only messages older than this one, for loading further back.
|
||||||
*/
|
*/
|
||||||
before_id?: number | null;
|
before_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Only messages saved after this one (by id, which is insertion
|
||||||
|
* order), for what arrived while a reply ran.
|
||||||
|
*/
|
||||||
|
after_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Only one side's messages.
|
||||||
|
*/
|
||||||
|
author?: "user" | "bot";
|
||||||
/**
|
/**
|
||||||
* Whose conversation it is, so a reaction can show as the user's own.
|
* Whose conversation it is, so a reaction can show as the user's own.
|
||||||
* Left out for the bot's own history reads, which don't need it.
|
* Left out for the bot's own history reads, which don't need it.
|
||||||
@@ -29,9 +39,12 @@ export default async function grabBotMessages({
|
|||||||
session_id,
|
session_id,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
before_id,
|
before_id,
|
||||||
|
after_id,
|
||||||
|
author,
|
||||||
user_id,
|
user_id,
|
||||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||||
let sql = ``;
|
let sql = ``;
|
||||||
|
const values: (string | number)[] = [session_id];
|
||||||
|
|
||||||
sql += `SELECT * FROM (\n`;
|
sql += `SELECT * FROM (\n`;
|
||||||
sql += ` SELECT * FROM bot_messages\n`;
|
sql += ` SELECT * FROM bot_messages\n`;
|
||||||
@@ -39,6 +52,17 @@ export default async function grabBotMessages({
|
|||||||
|
|
||||||
if (before_id) {
|
if (before_id) {
|
||||||
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
|
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
|
||||||
|
values.push(before_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (after_id) {
|
||||||
|
sql += ` AND id > ?\n`;
|
||||||
|
values.push(after_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author) {
|
||||||
|
sql += ` AND author = ?\n`;
|
||||||
|
values.push(author);
|
||||||
}
|
}
|
||||||
|
|
||||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||||
@@ -47,18 +71,19 @@ export default async function grabBotMessages({
|
|||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||||
sql,
|
sql,
|
||||||
values: before_id ? [session_id, before_id, limit] : [session_id, limit],
|
values: [...values, limit],
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = res.payload || [];
|
const messages = res.payload || [];
|
||||||
const message_ids = messages.map((message) => Number(message.id));
|
const message_ids = messages.map((message) => Number(message.id));
|
||||||
|
|
||||||
const [media, replies, reactions] = await Promise.all([
|
const [media, replies, reactions, usage] = await Promise.all([
|
||||||
grabMessagesMedia({ message_ids }),
|
grabMessagesMedia({ message_ids }),
|
||||||
grabMessagesReplies({ message_ids }),
|
grabMessagesReplies({ message_ids }),
|
||||||
user_id
|
user_id
|
||||||
? grabMessagesReactions({ message_ids, user_id })
|
? grabMessagesReactions({ message_ids, user_id })
|
||||||
: Promise.resolve(new Map()),
|
: Promise.resolve(new Map()),
|
||||||
|
grabMessageUsage({ message_ids }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +99,8 @@ export default async function grabBotMessages({
|
|||||||
const files = media.get(id);
|
const files = media.get(id);
|
||||||
const reply_to = replies.get(id);
|
const reply_to = replies.get(id);
|
||||||
|
|
||||||
|
const spent = usage.get(id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
...(files
|
...(files
|
||||||
@@ -88,6 +115,7 @@ export default async function grabBotMessages({
|
|||||||
: {}),
|
: {}),
|
||||||
...(reply_to ? { reply_to } : {}),
|
...(reply_to ? { reply_to } : {}),
|
||||||
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
|
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
|
||||||
|
...(spent ? { usage: spent } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full prompt for one of the user's bots (`composeBotPrompt`): its
|
* The full prompt for one of the user's bots (`composeBotPrompt`): who it
|
||||||
* core instructions, the install-wide appendix, its memory, what it
|
* is (its name and id), its core instructions, the install-wide appendix,
|
||||||
* remembers about this user, and in a workspace that workspace's context
|
* its memory, what it remembers about this user, and in a workspace that
|
||||||
* and what the bot has for it.
|
* workspace's context and what the bot has for it.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
||||||
const bot = await grabUserBot({ bot_id, user_id });
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
@@ -40,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
return composeBotPrompt({
|
return composeBotPrompt({
|
||||||
|
bot: { id: bot.id, name: bot.name, kind: bot.kind, role: bot.role },
|
||||||
system_prompt: bot.system_prompt,
|
system_prompt: bot.system_prompt,
|
||||||
appendix: await grabSystemPromptAppend(),
|
appendix: await grabSystemPromptAppend(),
|
||||||
memory: bot.memory,
|
memory: bot.memory,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type Params = {
|
|||||||
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
|
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
|
||||||
* coordinator is pinned first, then workers by latest activity. Each row
|
* coordinator is pinned first, then workers by latest activity. Each row
|
||||||
* carries the user's own face (`botFaceSql`) and conversation there: its
|
* carries the user's own face (`botFaceSql`) and conversation there: its
|
||||||
* unread flag, latest activity and latest message.
|
* unread flag, latest activity and latest message, and in a workspace
|
||||||
|
* whether it's that workspace's admin (`is_admin`).
|
||||||
*/
|
*/
|
||||||
export default async function grabBots({
|
export default async function grabBots({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -40,7 +41,8 @@ export default async function grabBots({
|
|||||||
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
|
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
|
||||||
sql += ` t.last_activity_at AS last_activity_at,\n`;
|
sql += ` t.last_activity_at AS last_activity_at,\n`;
|
||||||
sql += ` lm.text AS last_message_text,\n`;
|
sql += ` lm.text AS last_message_text,\n`;
|
||||||
sql += ` lm.created_at AS last_message_at\n`;
|
sql += ` lm.created_at AS last_message_at,\n`;
|
||||||
|
sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?) AS is_admin\n`;
|
||||||
sql += `FROM bots b\n`;
|
sql += `FROM bots b\n`;
|
||||||
sql += `LEFT JOIN (\n`;
|
sql += `LEFT JOIN (\n`;
|
||||||
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
|
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
|
||||||
@@ -59,8 +61,8 @@ export default async function grabBots({
|
|||||||
|
|
||||||
const res = await BunSQLite.sql<BotWithLastMessage>({
|
const res = await BunSQLite.sql<BotWithLastMessage>({
|
||||||
sql,
|
sql,
|
||||||
values: [...face.values, scope, user_id, scope, user_id, ...access.values],
|
values: [...face.values, scope, scope, user_id, scope, user_id, ...access.values],
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.payload || [];
|
return (res.payload || []).map((bot) => ({ ...bot, is_admin: Boolean(bot.is_admin) }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type Params = {
|
|||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
needs_you?: boolean;
|
needs_you?: boolean;
|
||||||
|
/**
|
||||||
|
* Sent mid-reply (`send_message`): the reply is still being worked on.
|
||||||
|
*/
|
||||||
|
interim?: boolean;
|
||||||
/**
|
/**
|
||||||
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
|
* Files the bot saved and sent with it (`media` ids): a PDF it made, a
|
||||||
* generated image, a document it wrote.
|
* generated image, a document it wrote.
|
||||||
@@ -30,10 +34,12 @@ export default async function insertBotMessage({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
text,
|
text,
|
||||||
needs_you,
|
needs_you,
|
||||||
|
interim,
|
||||||
media_ids = [],
|
media_ids = [],
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const needs_you_flag = needs_you ? 1 : 0;
|
const needs_you_flag = needs_you ? 1 : 0;
|
||||||
|
const interim_flag = interim ? 1 : 0;
|
||||||
|
|
||||||
const res = await BunSQLite.insert<
|
const res = await BunSQLite.insert<
|
||||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||||
@@ -48,6 +54,7 @@ export default async function insertBotMessage({
|
|||||||
author: "bot",
|
author: "bot",
|
||||||
text,
|
text,
|
||||||
needs_you: needs_you_flag,
|
needs_you: needs_you_flag,
|
||||||
|
interim: interim_flag,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -78,6 +85,7 @@ export default async function insertBotMessage({
|
|||||||
author: "bot",
|
author: "bot",
|
||||||
text,
|
text,
|
||||||
needs_you: needs_you_flag,
|
needs_you: needs_you_flag,
|
||||||
|
interim: interim_flag,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import logEvent from "@/src/functions/backend/logger";
|
||||||
|
import provisionComputer from "./provision-computer";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number;
|
||||||
|
/**
|
||||||
|
* Its container's `metadata`.
|
||||||
|
*/
|
||||||
|
metadata?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates a computer whose container still mounts its whole home (set up
|
||||||
|
* before only `AppData.ComputerWorkspaceDir` was mounted), so its bots work
|
||||||
|
* in the workspace alone. `provisionComputer` moves the old home aside
|
||||||
|
* (`prepareComputerDataDir`) and records the layout, so this runs once per
|
||||||
|
* computer. Returns whether it did.
|
||||||
|
*/
|
||||||
|
export default async function migrateComputerLayout({ computer_id, metadata }: Params) {
|
||||||
|
const layout = parseJobJson({ text: metadata || "" })?.layout;
|
||||||
|
|
||||||
|
if (layout == AppData.ComputerWorkspaceLayout) return false;
|
||||||
|
|
||||||
|
await provisionComputer({ computer_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: `Computer #${computer_id} now mounts only its workspace`,
|
||||||
|
category: "system",
|
||||||
|
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} is kept; its old home was moved aside and kept on disk.`,
|
||||||
|
metadata: { computer_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import grabComputerDataDir from "./grab-computer-data-dir";
|
import grabComputerDataDir from "./grab-computer-data-dir";
|
||||||
|
|
||||||
@@ -13,28 +14,45 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a computer's home directory, owned by the sandbox's user so it
|
* Creates a computer's workspace directory (mounted at
|
||||||
* can write there, and returns its path. Docker would otherwise create a
|
* `AppData.ComputerWorkspaceDir`), with its `bots` and `workspaces`
|
||||||
* missing mount source owned by root.
|
* folders, owned by the sandbox's user so it can write there, and returns
|
||||||
|
* its path. Docker would otherwise create a missing mount source owned by
|
||||||
|
* root.
|
||||||
|
*
|
||||||
|
* A directory without the layout marker (`AppData.ComputerWorkspaceMarker`)
|
||||||
|
* is a computer's whole home from before only the workspace was mounted:
|
||||||
|
* it is moved aside to `<id>-home-<timestamp>` and kept on disk (left out
|
||||||
|
* of data backups), so the computer starts with a clean workspace.
|
||||||
*/
|
*/
|
||||||
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
|
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
|
||||||
const dir = grabComputerDataDir({ computer_id });
|
const dir = grabComputerDataDir({ computer_id });
|
||||||
|
const marker = path.join(dir, AppData.ComputerWorkspaceMarker);
|
||||||
if (fresh) {
|
|
||||||
const existing = await fs.readdir(dir).catch(() => null);
|
const existing = await fs.readdir(dir).catch(() => null);
|
||||||
|
|
||||||
if (existing?.length) {
|
if (existing?.length) {
|
||||||
|
if (fresh) {
|
||||||
await fs.rename(dir, `${dir}-${Date.now()}`);
|
await fs.rename(dir, `${dir}-${Date.now()}`);
|
||||||
|
} else if (!(await fs.stat(marker).catch(() => null))) {
|
||||||
|
await fs.rename(dir, `${dir}${AppData.ComputerOldHomeSuffix}${Date.now()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
for (const folder of [dir, path.join(dir, "bots"), path.join(dir, "workspaces")]) {
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
await chownToSandbox({ target: folder });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
await fs.writeFile(marker, "", { flag: "a" });
|
||||||
* Only root can hand a directory to another user; a dev server
|
await chownToSandbox({ target: marker });
|
||||||
* running as that user already owns it.
|
|
||||||
*/
|
|
||||||
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
|
|
||||||
|
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only root can hand a file to another user; a dev server running as that
|
||||||
|
* user already owns it.
|
||||||
|
*/
|
||||||
|
async function chownToSandbox({ target }: { target: string }) {
|
||||||
|
await fs.chown(target, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,18 +10,20 @@ import pushComputerUpdate from "./push-computer-update";
|
|||||||
type Params = {
|
type Params = {
|
||||||
computer_id: number;
|
computer_id: number;
|
||||||
/**
|
/**
|
||||||
* A brand new computer: start from an empty home directory.
|
* A brand new computer: start from an empty workspace.
|
||||||
*/
|
*/
|
||||||
fresh?: boolean;
|
fresh?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up a computer's container: prepares its home directory, pulls the
|
* Sets up a computer's container: prepares its workspace directory, pulls the
|
||||||
* sandbox image and starts the container with the computer's resources
|
* sandbox image and starts the container with the computer's resources
|
||||||
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
||||||
* docker id, state, and its IP address on the network). A failure is
|
* docker id, state, and its IP address on the network). A failure is
|
||||||
* recorded as `failed` with its reason, which the computer lists show. The
|
* recorded as `failed` with its reason, which the computer lists show. The
|
||||||
* owner's tabs hear about it either way. Never throws.
|
* owner's tabs hear about it either way. The metadata's `layout` says the
|
||||||
|
* container mounts only the workspace (`syncComputers` recreates one that
|
||||||
|
* doesn't). Never throws.
|
||||||
*/
|
*/
|
||||||
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
||||||
const row = (
|
const row = (
|
||||||
@@ -65,7 +67,12 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
|
|||||||
created.ip_address,
|
created.ip_address,
|
||||||
AppData.ComputerPort,
|
AppData.ComputerPort,
|
||||||
created.status,
|
created.status,
|
||||||
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
|
JSON.stringify({
|
||||||
|
...kept,
|
||||||
|
data_dir,
|
||||||
|
host_dir: created.host_dir,
|
||||||
|
layout: AppData.ComputerWorkspaceLayout,
|
||||||
|
}),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
row.id,
|
row.id,
|
||||||
|
|||||||
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import resolveComputerHomeDir from "./resolve-computer-home-dir";
|
import resolveComputerWorkspaceDir from "./resolve-computer-workspace-dir";
|
||||||
|
|
||||||
const data_dir = "/data";
|
const data_dir = "/data";
|
||||||
|
|
||||||
describe("resolveComputerHomeDir", () => {
|
describe("resolveComputerWorkspaceDir", () => {
|
||||||
test("uses the directory the app bind-mounted", () => {
|
test("uses the directory the app bind-mounted", () => {
|
||||||
/**
|
/**
|
||||||
* The truth for a container the app provisioned: on a bind mount
|
* The truth for a container the app provisioned: on a bind mount
|
||||||
* that can be anywhere on the docker host, not under DATA_DIR.
|
* that can be anywhere on the docker host, not under DATA_DIR.
|
||||||
*/
|
*/
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 2,
|
computer_id: 2,
|
||||||
kind: "sandbox",
|
kind: "sandbox",
|
||||||
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
|
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
|
||||||
@@ -22,7 +22,7 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
|
|
||||||
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
|
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 1,
|
computer_id: 1,
|
||||||
kind: "main",
|
kind: "main",
|
||||||
metadata: null,
|
metadata: null,
|
||||||
@@ -34,7 +34,7 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
|
|
||||||
test("leaves a sandbox alone whatever the main setting says", () => {
|
test("leaves a sandbox alone whatever the main setting says", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({
|
resolveComputerWorkspaceDir({
|
||||||
computer_id: 3,
|
computer_id: 3,
|
||||||
kind: "sandbox",
|
kind: "sandbox",
|
||||||
metadata: null,
|
metadata: null,
|
||||||
@@ -45,26 +45,26 @@ describe("resolveComputerHomeDir", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("uses the conventional folder when nothing is recorded", () => {
|
test("uses the conventional folder when nothing is recorded", () => {
|
||||||
expect(resolveComputerHomeDir({ computer_id: 4, kind: "main", data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 4, kind: "main", data_dir })).toBe(
|
||||||
"/data/computers/4",
|
"/data/computers/4",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores an empty or unreadable record", () => {
|
test("ignores an empty or unreadable record", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
|
resolveComputerWorkspaceDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
|
||||||
).toBe("/data/computers/5");
|
).toBe("/data/computers/5");
|
||||||
expect(resolveComputerHomeDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
|
||||||
"/data/computers/5",
|
"/data/computers/5",
|
||||||
);
|
);
|
||||||
expect(resolveComputerHomeDir({ computer_id: 5, metadata: null, data_dir })).toBe(
|
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: null, data_dir })).toBe(
|
||||||
"/data/computers/5",
|
"/data/computers/5",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes a recorded relative path", () => {
|
test("normalizes a recorded relative path", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComputerHomeDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
|
resolveComputerWorkspaceDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
|
||||||
).toBe("/srv/b");
|
).toBe("/srv/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+7
-5
@@ -27,8 +27,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a computer's home (`AppData.ComputerHomeDir` in its container)
|
* Where a computer's workspace (`AppData.ComputerWorkspaceDir` in its
|
||||||
* actually is on this app's disk, for the files browser and for anything
|
* container, the only directory mounted from the data volume) actually is
|
||||||
|
* on this app's disk, for the files browser and for anything
|
||||||
* else that has to read a computer's files.
|
* else that has to read a computer's files.
|
||||||
*
|
*
|
||||||
* A container the app provisioned recorded the directory it bind-mounted
|
* A container the app provisioned recorded the directory it bind-mounted
|
||||||
@@ -36,15 +37,16 @@ type Params = {
|
|||||||
* that path can be anywhere the docker host allows. A computer the app
|
* that path can be anywhere the docker host allows. A computer the app
|
||||||
* didn't provision has no record. That is the install's main computer on a
|
* didn't provision has no record. That is the install's main computer on a
|
||||||
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
|
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
|
||||||
* whose sandbox keeps its home wherever its compose file says, so
|
* whose sandbox keeps its workspace wherever its compose file says, so
|
||||||
* `MAIN_COMPUTER_DATA_DIR` names that directory for this app. Without
|
* `MAIN_COMPUTER_DATA_DIR` names that directory (the host side of
|
||||||
|
* `AppData.ComputerWorkspaceDir`) for this app. Without
|
||||||
* either, the conventional place every app-provisioned computer uses:
|
* either, the conventional place every app-provisioned computer uses:
|
||||||
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
|
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
|
||||||
*
|
*
|
||||||
* Kept apart from the docker and database calls so the precedence is
|
* Kept apart from the docker and database calls so the precedence is
|
||||||
* testable on its own.
|
* testable on its own.
|
||||||
*/
|
*/
|
||||||
export default function resolveComputerHomeDir({
|
export default function resolveComputerWorkspaceDir({
|
||||||
computer_id,
|
computer_id,
|
||||||
kind,
|
kind,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -87,7 +87,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
|
|||||||
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
|
metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.` }) });
|
await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.`, log: false }) });
|
||||||
|
|
||||||
await logError({
|
await logError({
|
||||||
title: `Schedule #${id} couldn't run`,
|
title: `Schedule #${id} couldn't run`,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import hasEnvTemplate from "@/src/utils/has-env-template";
|
||||||
|
import literalUrlHost from "@/src/utils/literal-url-host";
|
||||||
|
|
||||||
|
type Params =
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws for what a bot may never propose, checked when it proposes and
|
||||||
|
* again on confirm: a stdio server (a command in the app's container), a
|
||||||
|
* URL whose host a template decides (where a key goes must be readable,
|
||||||
|
* and a bot must not pick it per call), and envs in a prompt tool (the
|
||||||
|
* model's answer would carry the secret back past redaction).
|
||||||
|
*/
|
||||||
|
export default function checkProposalData(params: Params) {
|
||||||
|
if (params.kind == "mcp") {
|
||||||
|
if (params.data.transport != "remote") {
|
||||||
|
throw new AppError(`Only remote MCP servers can be proposed. A stdio one is added by the user in Settings, under MCPs.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!literalUrlHost({ url: params.data.url })) {
|
||||||
|
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = params.data;
|
||||||
|
|
||||||
|
if (tool.kind == "http" && !literalUrlHost({ url: tool.config.url })) {
|
||||||
|
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.kind == "prompt" && hasEnvTemplate({ values: [tool.config.prompt] })) {
|
||||||
|
throw new AppError(`A prompt tool can't read envs: the model's answer would carry the value back.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal: ExtensionProposalRecord;
|
||||||
|
status: "confirmed" | "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides a proposal exactly once: only a pending one moves, and only a
|
||||||
|
* proposal that hasn't expired can be confirmed, so two tabs or a double
|
||||||
|
* click never save it twice. Throws why when it can't.
|
||||||
|
*/
|
||||||
|
export default async function claimExtensionProposal({ proposal, status }: Params) {
|
||||||
|
const now = Date.now();
|
||||||
|
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `UPDATE extension_proposals SET status = ?, decided_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
|
||||||
|
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimed.payload?.[0]) return;
|
||||||
|
|
||||||
|
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
|
||||||
|
|
||||||
|
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import grabAllEnvs from "../envs/grab-all-envs";
|
||||||
|
import grabCustomTool from "../extensions/grab-custom-tool";
|
||||||
|
import grabMcpServer from "../extensions/grab-mcp-server";
|
||||||
|
import saveCustomTool from "../extensions/save-custom-tool";
|
||||||
|
import saveMcpServer from "../extensions/save-mcp-server";
|
||||||
|
import grabSkill from "../extensions/grab-skill";
|
||||||
|
import saveSkill from "../extensions/save-skill";
|
||||||
|
import syncMcpServer from "../extensions/sync-mcp-server";
|
||||||
|
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||||
|
import checkProposalData from "./check-proposal-data";
|
||||||
|
import claimExtensionProposal from "./claim-extension-proposal";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
import grabProposalEnvs from "./grab-proposal-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
proposal_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user confirmed a proposal from its card: claims it (once, and only
|
||||||
|
* while it's pending and not expired), checks it again, then saves it
|
||||||
|
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
|
||||||
|
* that isn't saved yet is saved switched off, and the outcome names the
|
||||||
|
* keys to add. A remote server that's on is connected and its tools
|
||||||
|
* listed. Whatever happens is recorded on the proposal as its `outcome`.
|
||||||
|
*/
|
||||||
|
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
|
||||||
|
await claimExtensionProposal({ proposal, status: "confirmed" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (proposal.kind == "skill") {
|
||||||
|
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
|
||||||
|
|
||||||
|
const result_id = await saveSkill({
|
||||||
|
user_id,
|
||||||
|
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
|
||||||
|
});
|
||||||
|
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
|
||||||
|
!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [outcome, result_id, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkProposalData(proposal);
|
||||||
|
|
||||||
|
const { sends } = extensionEnvUses(proposal);
|
||||||
|
const missing = [
|
||||||
|
...new Set(
|
||||||
|
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const keys_note = missing.length
|
||||||
|
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
let outcome = "";
|
||||||
|
let result_id = 0;
|
||||||
|
|
||||||
|
if (proposal.kind == "mcp") {
|
||||||
|
if (proposal.extension_id) {
|
||||||
|
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
|
||||||
|
|
||||||
|
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||||
|
|
||||||
|
result_id = await saveMcpServer({ user_id, data });
|
||||||
|
|
||||||
|
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
|
||||||
|
|
||||||
|
outcome = `Saved as MCP server #${result_id}.${keys_note}${
|
||||||
|
!sync
|
||||||
|
? missing.length
|
||||||
|
? ""
|
||||||
|
: " It's off, so it wasn't connected."
|
||||||
|
: sync.ok
|
||||||
|
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||||
|
: ` It didn't connect: ${sync.error}`
|
||||||
|
}`;
|
||||||
|
} else {
|
||||||
|
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
|
||||||
|
|
||||||
|
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||||
|
|
||||||
|
result_id = await saveCustomTool({ user_id, data });
|
||||||
|
outcome = `Saved as tool #${result_id}.${keys_note}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [outcome, result_id, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
||||||
|
const message = redactSecrets({
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
secrets,
|
||||||
|
}).slice(0, 2000);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { SaveCustomToolRequest, SaveMcpServerRequest, SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import checkProposalData from "./check-proposal-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
session_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
research: string;
|
||||||
|
sources: string[];
|
||||||
|
} & (
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||||
|
| { kind: "skill"; data: SaveSkillRequest }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores what a bot proposes adding or changing (an MCP server, a tool or
|
||||||
|
* a skill), pending until the user
|
||||||
|
* confirms it from the chat. Nothing is saved, connected or run here. A
|
||||||
|
* proposal it makes again for the same server or tool in the same
|
||||||
|
* conversation replaces the one before, so only the latest card can be
|
||||||
|
* confirmed. Returns its id.
|
||||||
|
*/
|
||||||
|
export default async function createExtensionProposal(params: Params) {
|
||||||
|
if (params.kind != "skill") checkProposalData(params);
|
||||||
|
|
||||||
|
const extension_id =
|
||||||
|
(params.kind == "mcp"
|
||||||
|
? params.data.mcp_server_id
|
||||||
|
: params.kind == "skill"
|
||||||
|
? params.data.skill_id
|
||||||
|
: params.data.custom_tool_id) || undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "extension_proposals",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kind: params.kind,
|
||||||
|
extension_id,
|
||||||
|
name: params.data.name,
|
||||||
|
data: JSON.stringify(params.data),
|
||||||
|
research: params.research,
|
||||||
|
sources: JSON.stringify(params.sources),
|
||||||
|
status: "pending",
|
||||||
|
outcome: "",
|
||||||
|
expires_at: now + AppData.ExtensionProposalTtlMs,
|
||||||
|
user_id: Number(params.user_id),
|
||||||
|
bot_id: Number(params.bot_id),
|
||||||
|
session_id: Number(params.session_id),
|
||||||
|
workspace_id: params.workspace_id ? Number(params.workspace_id) : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||||
|
|
||||||
|
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
|
||||||
|
WHERE session_id = ? AND kind = ? AND status = 'pending' AND id != ?
|
||||||
|
AND (${extension_id ? "extension_id = ?" : "extension_id IS NULL AND name = ? COLLATE NOCASE"})`,
|
||||||
|
values: [
|
||||||
|
`Replaced by proposal #${id}.`,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
Number(params.session_id),
|
||||||
|
params.kind,
|
||||||
|
id,
|
||||||
|
extension_id || params.data.name,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ExtensionProposalView } from "@/src/types";
|
||||||
|
import describeProposalDetails from "@/src/utils/describe-proposal-details";
|
||||||
|
import describeSkillProposal from "@/src/utils/describe-skill-proposal";
|
||||||
|
import grabSkill from "../extensions/grab-skill";
|
||||||
|
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
import grabProposalEnvs from "./grab-proposal-envs";
|
||||||
|
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A proposal as its chip and confirm card show it: the facts (host, envs
|
||||||
|
* and whether each is saved, every detail of the config; for a skill what
|
||||||
|
* its runbook points at and what changed) worked out from the config now,
|
||||||
|
* so a key added since shows as saved; the bot's research beside them,
|
||||||
|
* labelled as its own.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionProposalView({ proposal_id, user_id }: Params): Promise<ExtensionProposalView> {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
const facts = await grabProposalFacts({ proposal });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: proposal.id,
|
||||||
|
kind: proposal.kind,
|
||||||
|
extension_id: proposal.extension_id,
|
||||||
|
name: proposal.name,
|
||||||
|
status: proposal.status,
|
||||||
|
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
|
||||||
|
expires_at: proposal.expires_at,
|
||||||
|
outcome: proposal.outcome,
|
||||||
|
result_id: proposal.result_id,
|
||||||
|
...facts,
|
||||||
|
research: proposal.research,
|
||||||
|
sources: proposal.sources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The facts a proposal's card shows, worked out now: for a server or tool
|
||||||
|
* its host, its envs and whether each is saved, and its config; for a
|
||||||
|
* skill what its runbook points bots at and what changed against the
|
||||||
|
* saved one (a deleted skill shows as new).
|
||||||
|
*/
|
||||||
|
async function grabProposalFacts({ proposal }: { proposal: ExtensionProposalRecord }) {
|
||||||
|
if (proposal.kind == "skill") {
|
||||||
|
const current = proposal.extension_id
|
||||||
|
? await grabSkill({ skill_id: proposal.extension_id }).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { host: null, envs: [], details: describeSkillProposal({ data: proposal.data, current }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed =
|
||||||
|
proposal.kind == "mcp"
|
||||||
|
? ({ kind: "mcp", data: proposal.data } as const)
|
||||||
|
: ({ kind: "tool", data: proposal.data } as const);
|
||||||
|
const { host, sends } = extensionEnvUses(typed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
envs: await grabProposalEnvs({ sends }),
|
||||||
|
details: describeProposalDetails({ ...typed, is_new: !proposal.extension_id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseProposalRow from "./parse-proposal-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
proposal_id: number | string;
|
||||||
|
/**
|
||||||
|
* The super admin it was proposed to: nobody else reads it.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One proposal made to this user, or an `AppError`.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionProposal({ proposal_id, user_id }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS>({
|
||||||
|
sql: `SELECT * FROM extension_proposals WHERE id = ? AND user_id = ?`,
|
||||||
|
values: [proposal_id, user_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row) throw new AppError(`That proposal doesn't exist.`);
|
||||||
|
|
||||||
|
return parseProposalRow({ row });
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { EnvDict } from "@/src/dict/env-dict";
|
||||||
|
import type { ExtensionProposalEnv } from "@/src/types";
|
||||||
|
import type { ExtensionEnvUse } from "@/src/utils/extension-env-uses";
|
||||||
|
import grabAllEnvs from "../envs/grab-all-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
sends: ExtensionEnvUse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each env a proposed server or tool sends, with where it's saved: the
|
||||||
|
* scopes templates read (`grabRunEnvs`: general, a bot's own, a
|
||||||
|
* workspace's), named. App settings never fill a template, so one of
|
||||||
|
* those keys reads as missing.
|
||||||
|
*/
|
||||||
|
export default async function grabProposalEnvs({ sends }: Params): Promise<ExtensionProposalEnv[]> {
|
||||||
|
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||||
|
const envs = (await grabAllEnvs()).filter(
|
||||||
|
(env) => !app_keys.has(env.key) && ["general", "bot", "workspace"].includes(env.paradigm),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bot_ids = [...new Set(envs.map((env) => env.bot_id).filter(Boolean))];
|
||||||
|
const workspace_ids = [...new Set(envs.map((env) => env.workspace_id).filter(Boolean))];
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
names({ table: "bots", ids: bot_ids as number[] }),
|
||||||
|
names({ table: "workspaces", ids: workspace_ids as number[] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return sends.map((use) => ({
|
||||||
|
...use,
|
||||||
|
saved_in: envs
|
||||||
|
.filter((env) => env.key == use.key)
|
||||||
|
.map((env) =>
|
||||||
|
env.paradigm == "general"
|
||||||
|
? "general"
|
||||||
|
: env.paradigm == "bot"
|
||||||
|
? `bot ${bots.get(Number(env.bot_id)) || env.bot_id} only`
|
||||||
|
: `workspace ${workspaces.get(Number(env.workspace_id)) || env.workspace_id} only`,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function names({ table, ids }: { table: "bots" | "workspaces"; ids: number[] }) {
|
||||||
|
if (!ids.length) return new Map<number, string>();
|
||||||
|
|
||||||
|
const rows = await BunSQLite.sql<{ id: number; name: string }>({
|
||||||
|
sql: `SELECT id, name FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")})`,
|
||||||
|
values: ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map((rows.payload || []).map((row) => [Number(row.id), row.name || ""]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||||
|
import type { ExtensionProposalKind, ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
|
||||||
|
import {
|
||||||
|
SaveCustomToolRequestSchema,
|
||||||
|
SaveMcpServerRequestSchema,
|
||||||
|
SaveSkillRequestSchema,
|
||||||
|
type SaveCustomToolRequest,
|
||||||
|
type SaveMcpServerRequest,
|
||||||
|
type SaveSkillRequest,
|
||||||
|
} from "@/src/schemas/extension-schema";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import parseJsonColumn from "../extensions/parse-json-column";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
row: BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionProposalRecord = {
|
||||||
|
id: number;
|
||||||
|
extension_id: number | null;
|
||||||
|
name: string;
|
||||||
|
research: string;
|
||||||
|
sources: string[];
|
||||||
|
status: ExtensionProposalStatus;
|
||||||
|
outcome: string;
|
||||||
|
result_id: number | null;
|
||||||
|
expires_at: number;
|
||||||
|
user_id: number;
|
||||||
|
bot_id: number;
|
||||||
|
session_id: number;
|
||||||
|
} & (
|
||||||
|
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||||
|
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||||
|
| { kind: "skill"; data: SaveSkillRequest }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `extension_proposals` row with its config read and checked again by
|
||||||
|
* the settings forms' schemas, so what confirm saves is what they'd allow.
|
||||||
|
*/
|
||||||
|
export default function parseProposalRow({ row }: Params): ExtensionProposalRecord {
|
||||||
|
const kind = (row.kind || "mcp") as ExtensionProposalKind;
|
||||||
|
const raw = parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} });
|
||||||
|
const base = {
|
||||||
|
id: Number(row.id),
|
||||||
|
extension_id: row.extension_id ? Number(row.extension_id) : null,
|
||||||
|
name: row.name || "",
|
||||||
|
research: row.research || "",
|
||||||
|
sources: parseJsonColumn<string[]>({ text: row.sources, fallback: [] }),
|
||||||
|
status: (row.status || "pending") as ExtensionProposalStatus,
|
||||||
|
outcome: row.outcome || "",
|
||||||
|
result_id: row.result_id ? Number(row.result_id) : null,
|
||||||
|
expires_at: Number(row.expires_at) || 0,
|
||||||
|
user_id: Number(row.user_id),
|
||||||
|
bot_id: Number(row.bot_id),
|
||||||
|
session_id: Number(row.session_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind == "mcp") return { ...base, kind, data: parseWithSchema({ schema: SaveMcpServerRequestSchema, data: raw }) };
|
||||||
|
|
||||||
|
if (kind == "skill") return { ...base, kind, data: parseWithSchema({ schema: SaveSkillRequestSchema, data: raw }) };
|
||||||
|
|
||||||
|
return { ...base, kind: "tool", data: parseWithSchema({ schema: SaveCustomToolRequestSchema, data: raw }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import claimExtensionProposal from "./claim-extension-proposal";
|
||||||
|
import grabExtensionProposal from "./grab-extension-proposal";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
proposal_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user rejected a proposal from its card: nothing is saved.
|
||||||
|
*/
|
||||||
|
export default async function rejectExtensionProposal({ user_id, proposal_id }: Params) {
|
||||||
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||||
|
|
||||||
|
await claimExtensionProposal({ proposal, status: "rejected" });
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE extension_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Date.now(), proposal.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return proposal;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
||||||
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import { rmSync } from "fs";
|
import { rmSync } from "fs";
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
@@ -22,7 +21,6 @@ type Params = {
|
|||||||
export default async function deleteMedia(params: Params) {
|
export default async function deleteMedia(params: Params) {
|
||||||
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
|
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
|
||||||
|
|
||||||
const { DATA_DIR } = grabDirNames();
|
|
||||||
|
|
||||||
let media_to_delete = new Set<number>();
|
let media_to_delete = new Set<number>();
|
||||||
|
|
||||||
@@ -87,18 +85,13 @@ export default async function deleteMedia(params: Params) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target_media.media_write_path) {
|
for (const write_path of [
|
||||||
rmSync(
|
target_media.media_write_path,
|
||||||
path.join(DATA_DIR, target_media.media_write_path),
|
target_media.media_thumbnail_write_path,
|
||||||
{ force: true },
|
]) {
|
||||||
);
|
const absolute = mediaFilePath({ write_path });
|
||||||
}
|
|
||||||
|
|
||||||
if (target_media.media_thumbnail_write_path) {
|
if (absolute) rmSync(absolute, { force: true });
|
||||||
rmSync(
|
|
||||||
path.join(DATA_DIR, target_media.media_thumbnail_write_path),
|
|
||||||
{ force: true },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
|
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import path from "node:path";
|
import mediaFilePath from "@/src/utils/media-file-path";
|
||||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
|
import { extensionOf } from "@/src/functions/backend/db/bot-files/media-type-for-file";
|
||||||
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
|
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
|
||||||
import type { MediaParadigm } from "@/src/types";
|
import type { MediaParadigm } from "@/src/types";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
|
||||||
|
|
||||||
export type PictureTarget = "bot" | "workspace";
|
export type PictureTarget = "bot" | "workspace";
|
||||||
|
|
||||||
@@ -102,13 +101,13 @@ export default async function copyMediaAsPicture({
|
|||||||
* The image's bytes, read from where `media` keeps them.
|
* The image's bytes, read from where `media` keeps them.
|
||||||
*/
|
*/
|
||||||
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
|
async function readMediaBytes({ media }: { media: BUN_SQLITE_COBBLE_MEDIA }) {
|
||||||
const { DATA_DIR } = grabDirNames();
|
const absolute = mediaFilePath({ write_path: media.media_write_path });
|
||||||
|
|
||||||
if (!media.media_write_path) {
|
if (!absolute) {
|
||||||
throw new AppError(`Its file is missing on the server. Save it again.`);
|
throw new AppError(`Its file is missing on the server. Save it again.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(path.join(DATA_DIR, media.media_write_path));
|
const file = Bun.file(absolute);
|
||||||
|
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
throw new AppError(`Its file is missing on the server. Save it again.`);
|
throw new AppError(`Its file is missing on the server. Save it again.`);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
is_coordinator: boolean;
|
is_coordinator: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is admin of (`grabBotRunPowers`).
|
||||||
|
*/
|
||||||
|
admin_workspace_ids?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +26,9 @@ export default async function grabRunTask({
|
|||||||
task_id,
|
task_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
is_coordinator,
|
is_coordinator,
|
||||||
|
admin_workspace_ids,
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
|
||||||
const access = botTaskScopesSql({ bot_id, is_coordinator });
|
const access = botTaskScopesSql({ bot_id, is_coordinator, admin_workspace_ids });
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||||
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
|
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
is_coordinator?: boolean;
|
is_coordinator?: boolean;
|
||||||
|
/**
|
||||||
|
* The workspaces the bot is admin of (`grabBotRunPowers`).
|
||||||
|
*/
|
||||||
|
admin_workspace_ids?: number[];
|
||||||
/**
|
/**
|
||||||
* Only one collection.
|
* Only one collection.
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +48,7 @@ export default async function grabTasks({
|
|||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
is_coordinator,
|
is_coordinator,
|
||||||
|
admin_workspace_ids,
|
||||||
only_bot_id,
|
only_bot_id,
|
||||||
only_workspace_id,
|
only_workspace_id,
|
||||||
statuses,
|
statuses,
|
||||||
@@ -58,7 +63,7 @@ export default async function grabTasks({
|
|||||||
where.push(access.sql);
|
where.push(access.sql);
|
||||||
values.push(...access.values);
|
values.push(...access.values);
|
||||||
} else if (bot_id != null) {
|
} else if (bot_id != null) {
|
||||||
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator) });
|
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator), admin_workspace_ids });
|
||||||
where.push(access.sql);
|
where.push(access.sql);
|
||||||
values.push(...access.values);
|
values.push(...access.values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import bucketUsageDays, { localDate } from "./bucket-usage-days";
|
||||||
|
import { DayMs, startOfDay } from "./grab-usage-window";
|
||||||
|
|
||||||
|
function row(local_day: string, tokens: number) {
|
||||||
|
return {
|
||||||
|
local_day,
|
||||||
|
calls: 1,
|
||||||
|
prompt_tokens: tokens,
|
||||||
|
completion_tokens: 0,
|
||||||
|
cost: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date(2026, 9, 23, 15, 0, 0).getTime();
|
||||||
|
const end = now;
|
||||||
|
const start = startOfDay({ at: now }) - 2 * DayMs;
|
||||||
|
|
||||||
|
describe("localDate", () => {
|
||||||
|
test("names the local calendar day, not the UTC one", () => {
|
||||||
|
/**
|
||||||
|
* Half an hour past local midnight is still the previous UTC day
|
||||||
|
* for anyone east of Greenwich, and the bug the chart had: the row
|
||||||
|
* was counted under one name and looked for under another.
|
||||||
|
*/
|
||||||
|
const at = new Date(2026, 9, 23, 0, 30, 0).getTime();
|
||||||
|
|
||||||
|
expect(localDate({ at })).toBe("2026-10-23");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pads a single-digit month and day", () => {
|
||||||
|
expect(localDate({ at: new Date(2026, 0, 5, 12, 0, 0).getTime() })).toBe("2026-01-05");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bucketUsageDays", () => {
|
||||||
|
test("one bucket per day of the window, oldest first", () => {
|
||||||
|
const days = bucketUsageDays({ rows: [], start, end });
|
||||||
|
|
||||||
|
expect(days.length).toBe(3);
|
||||||
|
expect(days.map((day) => day.label)).toEqual(
|
||||||
|
[2, 1, 0].map((back) => localDate({ at: startOfDay({ at: now }) - back * DayMs })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every row lands in its day, and yesterday's late call does not vanish", () => {
|
||||||
|
const today = localDate({ at: startOfDay({ at: now }) });
|
||||||
|
const yesterday = localDate({ at: startOfDay({ at: now }) - DayMs });
|
||||||
|
|
||||||
|
const days = bucketUsageDays({
|
||||||
|
rows: [row(today, 1500), row(yesterday, 400)],
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const by_label = new Map(days.map((day) => [day.label, day.total_tokens]));
|
||||||
|
|
||||||
|
expect(by_label.get(today)).toBe(1500);
|
||||||
|
expect(by_label.get(yesterday)).toBe(400);
|
||||||
|
expect(days.reduce((total, day) => total + day.total_tokens, 0)).toBe(1900);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a day with nothing in it is still a bucket, at zero", () => {
|
||||||
|
const days = bucketUsageDays({
|
||||||
|
rows: [row(localDate({ at: startOfDay({ at: now }) }), 100)],
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(days.filter((day) => day.total_tokens === 0).length).toBe(2);
|
||||||
|
expect(days.every((day) => day.calls === 0 || day.calls === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bucket's midnight is that day's local midnight", () => {
|
||||||
|
const days = bucketUsageDays({ rows: [], start, end });
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
expect(day.at).toBe(startOfDay({ at: day.at }));
|
||||||
|
expect(localDate({ at: day.at })).toBe(day.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cost and calls are summed, not counted twice", () => {
|
||||||
|
const label = localDate({ at: startOfDay({ at: now }) });
|
||||||
|
|
||||||
|
const days = bucketUsageDays({
|
||||||
|
rows: [
|
||||||
|
{ ...row(label, 100), calls: 3, cost: 0.25 },
|
||||||
|
{ ...row(label, 50), calls: 2, cost: 0.5 },
|
||||||
|
],
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucket = days.find((day) => day.label === label);
|
||||||
|
|
||||||
|
expect(bucket?.total_tokens).toBe(150);
|
||||||
|
expect(bucket?.cost).toBe(0.75);
|
||||||
|
expect(days.reduce((total, day) => total + day.calls, 0)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { DayMs, startOfDay } from "./grab-usage-window";
|
||||||
|
import { toSummary } from "./grab-usage-totals";
|
||||||
|
import type { TokenUsageSummary } from "@/src/harness/usage/types";
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
/**
|
||||||
|
* The day the call was made, `YYYY-MM-DD`, already in local time.
|
||||||
|
*/
|
||||||
|
local_day: string;
|
||||||
|
calls: number | null;
|
||||||
|
prompt_tokens: number | null;
|
||||||
|
completion_tokens: number | null;
|
||||||
|
cost: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
rows: Row[];
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsageDay = {
|
||||||
|
/**
|
||||||
|
* The day's local midnight (epoch ms).
|
||||||
|
*/
|
||||||
|
at: number;
|
||||||
|
/**
|
||||||
|
* `YYYY-MM-DD`, in local time.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
} & TokenUsageSummary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows SQL grouped by local day, laid onto one bucket per day of the
|
||||||
|
* window, oldest first. Days with nothing in them are still there, at
|
||||||
|
* zero, so the bars keep their spacing, and every row lands in a bucket:
|
||||||
|
* a day outside the window would be a bug in the query's own bounds.
|
||||||
|
*/
|
||||||
|
export default function bucketUsageDays({ rows, start, end }: Params): UsageDay[] {
|
||||||
|
const by_day = new Map<string, TokenUsageSummary>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
/**
|
||||||
|
* Accumulated, not replaced: the query groups by day, so a repeated
|
||||||
|
* day means someone asked for two sets of rows, and the later one
|
||||||
|
* must add to the earlier rather than hide it.
|
||||||
|
*/
|
||||||
|
const label = String(row.local_day);
|
||||||
|
const summary = toSummary({ row });
|
||||||
|
|
||||||
|
by_day.set(label, mergeSummaries({ into: by_day.get(label), summary }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const days: UsageDay[] = [];
|
||||||
|
const last = startOfDay({ at: end });
|
||||||
|
|
||||||
|
for (let at = startOfDay({ at: start }); at <= last; at += DayMs) {
|
||||||
|
const label = localDate({ at });
|
||||||
|
|
||||||
|
days.push({
|
||||||
|
at,
|
||||||
|
label,
|
||||||
|
...(by_day.get(label) || emptySummary()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day a local midnight falls on, in local time.
|
||||||
|
*
|
||||||
|
* Never `toISOString`: an epoch millisecond read in UTC is a different
|
||||||
|
* calendar day for anyone east or west of Greenwich, and a call counted
|
||||||
|
* under one day would then look for a bucket named after another and
|
||||||
|
* disappear from the chart (which is exactly what it did before).
|
||||||
|
*/
|
||||||
|
export function localDate({ at }: { at: number }) {
|
||||||
|
const date = new Date(at);
|
||||||
|
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
String(date.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(date.getDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySummary(): TokenUsageSummary {
|
||||||
|
return { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, calls: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One summary added onto another. A day that reported no cost still costs
|
||||||
|
* nothing; only a real cost is carried, so an unpriced day doesn't turn a
|
||||||
|
* priced one into zero.
|
||||||
|
*/
|
||||||
|
function mergeSummaries({
|
||||||
|
into,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
into?: TokenUsageSummary;
|
||||||
|
summary: TokenUsageSummary;
|
||||||
|
}): TokenUsageSummary {
|
||||||
|
const prompt_tokens = (into?.prompt_tokens || 0) + summary.prompt_tokens;
|
||||||
|
const completion_tokens = (into?.completion_tokens || 0) + summary.completion_tokens;
|
||||||
|
const cost = (into?.cost || 0) + (summary.cost || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens: prompt_tokens + completion_tokens,
|
||||||
|
...(cost > 0 ? { cost } : {}),
|
||||||
|
calls: (into?.calls || 0) + summary.calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { MessageDetails, MessageDetailsCall, MessageDetailsJob } from "@/src/types";
|
||||||
|
import groupMessageCalls from "@/src/utils/group-message-calls";
|
||||||
|
import parseCallSettings from "@/src/utils/parse-call-settings";
|
||||||
|
import grabMessageUsage from "./grab-message-usage";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* A message the caller already checked the user reaches.
|
||||||
|
*/
|
||||||
|
message_id: number;
|
||||||
|
created_at?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CallRow = {
|
||||||
|
id: number;
|
||||||
|
created_at: number;
|
||||||
|
harness: string | null;
|
||||||
|
provider_id: number | null;
|
||||||
|
provider_name: string | null;
|
||||||
|
provider_kind: string | null;
|
||||||
|
model_slug: string | null;
|
||||||
|
model_name: string | null;
|
||||||
|
prompt_tokens: number | null;
|
||||||
|
completion_tokens: number | null;
|
||||||
|
cost: number | null;
|
||||||
|
settings: string | null;
|
||||||
|
job_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JobRow = {
|
||||||
|
id: number;
|
||||||
|
kind: string | null;
|
||||||
|
title: string | null;
|
||||||
|
status: string | null;
|
||||||
|
attempts: number | null;
|
||||||
|
started_at: number | null;
|
||||||
|
finished_at: number | null;
|
||||||
|
steps: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one message was generated: every model call it took (`token_usages`)
|
||||||
|
* with the provider and model that answered, summed per model, and the
|
||||||
|
* queued job that ran it, while it is kept. The provider's kind and the
|
||||||
|
* model's display name come from their live rows, so a deleted provider
|
||||||
|
* or an unlisted model still reads by the name it was called with.
|
||||||
|
*/
|
||||||
|
export default async function grabMessageDetails({ message_id, created_at }: Params): Promise<MessageDetails> {
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<CallRow>({
|
||||||
|
sql: `SELECT t.id, t.created_at, t.harness, t.provider_id, t.provider_name,
|
||||||
|
p.kind AS provider_kind, t.model_slug, m.name AS model_name,
|
||||||
|
t.prompt_tokens, t.completion_tokens, t.cost, t.settings, t.job_id
|
||||||
|
FROM token_usages t
|
||||||
|
LEFT JOIN harness_providers p ON p.id = t.provider_id
|
||||||
|
LEFT JOIN harness_models m ON m.id = t.model_id
|
||||||
|
WHERE t.message_id = ?
|
||||||
|
ORDER BY t.created_at ASC, t.id ASC`,
|
||||||
|
values: [message_id],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
const calls: MessageDetailsCall[] = rows.map((row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
created_at: Number(row.created_at) || 0,
|
||||||
|
harness: row.harness || "text",
|
||||||
|
provider_id: row.provider_id ? Number(row.provider_id) : null,
|
||||||
|
provider_name: row.provider_name || null,
|
||||||
|
provider_kind: row.provider_kind || null,
|
||||||
|
model_slug: row.model_slug || null,
|
||||||
|
model_name: row.model_name || null,
|
||||||
|
prompt_tokens: Number(row.prompt_tokens) || 0,
|
||||||
|
completion_tokens: Number(row.completion_tokens) || 0,
|
||||||
|
cost: Number.isFinite(Number(row.cost)) && row.cost !== null ? Number(row.cost) : null,
|
||||||
|
settings: parseCallSettings({ json: row.settings }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = (await grabMessageUsage({ message_ids: [message_id] })).get(message_id) || {
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
calls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job is the one its calls ran for: the latest, since reading a
|
||||||
|
* message aloud later is a call without one.
|
||||||
|
*/
|
||||||
|
const job_id = [...rows].reverse().find((row) => row.job_id)?.job_id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message_id,
|
||||||
|
created_at: created_at || null,
|
||||||
|
totals,
|
||||||
|
models: groupMessageCalls({ calls }),
|
||||||
|
calls: calls.slice(0, AppData.MessageDetailsMaxCalls),
|
||||||
|
job: job_id ? await grabJob({ job_id: Number(job_id) }) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grabJob({ job_id }: { job_id: number }): Promise<MessageDetailsJob | null> {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<JobRow>({
|
||||||
|
sql: `SELECT q.id, q.kind, q.title, q.status, q.attempts, q.started_at, q.finished_at,
|
||||||
|
(SELECT COUNT(*) FROM bots_queue_updates u WHERE u.bots_queue_id = q.id) AS steps
|
||||||
|
FROM bots_queue q WHERE q.id = ? LIMIT 1`,
|
||||||
|
values: [job_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row?.id) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
kind: row.kind || null,
|
||||||
|
title: row.title || null,
|
||||||
|
status: row.status || null,
|
||||||
|
attempts: Number(row.attempts) || 0,
|
||||||
|
started_at: Number(row.started_at) || null,
|
||||||
|
finished_at: Number(row.finished_at) || null,
|
||||||
|
steps: Number(row.steps) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user