mirror of
https://github.com/kennethnym/freya
synced 2026-06-18 15:46:12 +01:00
Compare commits
4 Commits
feat/agent
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e71fb828 | |||
| e9f97d6f02 | |||
| e52e057548 | |||
| d3d9def260 |
11
AGENTS.md
11
AGENTS.md
@@ -39,4 +39,13 @@ Use Bun exclusively. Do not use npm or yarn.
|
|||||||
|
|
||||||
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
|
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
|
||||||
- Commits: conventional commit format, title <= 50 chars
|
- Commits: conventional commit format, title <= 50 chars
|
||||||
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`
|
|
||||||
|
## Nix
|
||||||
|
|
||||||
|
Use the Nix dev shell for project commands by default.
|
||||||
|
|
||||||
|
- Run repo tooling through `nix develop -c`, e.g. `nix develop -c bun test`.
|
||||||
|
- Use Bun exclusively inside the Nix shell.
|
||||||
|
- Do not use host `bun`, `node`, `tsc`, or package binaries for project tasks unless explicitly checking host behavior.
|
||||||
|
- Simple inspection commands like `rg`, `sed`, `ls`, and `git status` may run outside Nix.
|
||||||
|
- While `flake.nix` is untracked, use `nix develop path:. -c <command>`.
|
||||||
|
|||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781577229,
|
||||||
|
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
280
flake.nix
Normal file
280
flake.nix
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
{
|
||||||
|
description = "FREYA development shell";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
lib = nixpkgs.lib;
|
||||||
|
forEachSystem = lib.genAttrs systems;
|
||||||
|
pkgsFor = forEachSystem (system: import nixpkgs { inherit system; });
|
||||||
|
|
||||||
|
# App outputs are for long-running local tools and dev servers.
|
||||||
|
appScripts = {
|
||||||
|
expo = "expo";
|
||||||
|
drizzle-studio = "drizzle-studio";
|
||||||
|
freya-backend = "freya-backend";
|
||||||
|
admin-dashboard = "admin-dashboard";
|
||||||
|
agent-test-cli = "agent-test-cli";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Check outputs are the CI-like validation commands run by `nix flake check`.
|
||||||
|
checkCommands = {
|
||||||
|
format-check = "bun run format:check";
|
||||||
|
lint = "bun run lint";
|
||||||
|
test = "bun run test";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Dev-shell conveniences mirror the common app/check commands.
|
||||||
|
shellScripts = appScripts // {
|
||||||
|
freya-test = "test";
|
||||||
|
lint = "lint";
|
||||||
|
format-check = "format:check";
|
||||||
|
};
|
||||||
|
|
||||||
|
# node_modules is content-addressed. If bun.lock or package manifests
|
||||||
|
# change, Nix will report the new hash to put here.
|
||||||
|
nodeModulesHashes = {
|
||||||
|
x86_64-linux = "sha256-apVZaFGf9OKpil1WdcQ1CJODsIdjLWlBBZErHg5mjZA=";
|
||||||
|
};
|
||||||
|
checkSystems = lib.attrNames nodeModulesHashes;
|
||||||
|
|
||||||
|
# Dependency derivations only need the lockfile and workspace manifests,
|
||||||
|
# so source-only edits do not force Bun to reinstall.
|
||||||
|
dependencySource = lib.fileset.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = lib.fileset.fileFilter (file: file.name == "bun.lock" || file.name == "package.json") ./.;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Checks run against a clean source tree, even when using `path:.`.
|
||||||
|
# Without this filter, local node_modules can sneak into the Nix sandbox.
|
||||||
|
projectSource = builtins.path {
|
||||||
|
name = "freya-source";
|
||||||
|
path = ./.;
|
||||||
|
filter =
|
||||||
|
path: type:
|
||||||
|
let
|
||||||
|
name = builtins.baseNameOf path;
|
||||||
|
in
|
||||||
|
!(type == "directory" && (name == ".git" || name == "node_modules")) && name != "result";
|
||||||
|
};
|
||||||
|
|
||||||
|
mkBunScriptCommands =
|
||||||
|
pkgs: scripts:
|
||||||
|
let
|
||||||
|
mkBunScript =
|
||||||
|
name: script:
|
||||||
|
pkgs.writeShellApplication {
|
||||||
|
inherit name;
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
bun
|
||||||
|
git
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
exec bun run ${lib.escapeShellArg script} "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
lib.mapAttrs mkBunScript scripts;
|
||||||
|
mkBunApps =
|
||||||
|
commands:
|
||||||
|
lib.mapAttrs (name: command: {
|
||||||
|
type = "app";
|
||||||
|
program = "${command}/bin/${name}";
|
||||||
|
}) commands;
|
||||||
|
mkBunNodeModules =
|
||||||
|
system: pkgs:
|
||||||
|
pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "freya-node-modules";
|
||||||
|
version = "1";
|
||||||
|
__structuredAttrs = true;
|
||||||
|
|
||||||
|
src = dependencySource;
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
bun
|
||||||
|
cacert
|
||||||
|
nodejs
|
||||||
|
];
|
||||||
|
|
||||||
|
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
GIT_SSL_CAINFO = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHash = nodeModulesHashes.${system};
|
||||||
|
|
||||||
|
# `patchShebangs` embeds Nix store interpreters in package bins. The
|
||||||
|
# check derivations also depend on bun/node, so this dependency blob
|
||||||
|
# can safely drop those references after its hash is verified.
|
||||||
|
unsafeDiscardReferences.out = true;
|
||||||
|
|
||||||
|
dontConfigure = true;
|
||||||
|
# Workspace package links are completed inside each check's source tree,
|
||||||
|
# so they are intentionally dangling in this dependency-only output.
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
# Keep the real workspace manifest for `--frozen-lockfile`, but
|
||||||
|
# filter out frontend workspaces that do not participate in checks.
|
||||||
|
# `--force` matters in the Nix sandbox: without it, Bun can accept
|
||||||
|
# manifest-only cached packages and leave tool binaries missing.
|
||||||
|
bun install \
|
||||||
|
--force \
|
||||||
|
--frozen-lockfile \
|
||||||
|
--ignore-scripts \
|
||||||
|
--backend copyfile \
|
||||||
|
--filter freya \
|
||||||
|
--filter '@freya/*' \
|
||||||
|
--filter '@freya/backend' \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
patchShebangs node_modules
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
|
||||||
|
# Keep the root install in the store; checks symlink this directly.
|
||||||
|
cp -a node_modules "$out/node_modules"
|
||||||
|
|
||||||
|
# Bun also creates per-workspace node_modules directories. These are
|
||||||
|
# mostly relative symlinks, so checks copy the symlink entries into
|
||||||
|
# their writable source tree instead of symlinking the directory.
|
||||||
|
find apps packages -mindepth 2 -maxdepth 2 -type d -name node_modules -print |
|
||||||
|
while IFS= read -r node_modules_dir; do
|
||||||
|
mkdir -p "$out/$(dirname "$node_modules_dir")"
|
||||||
|
cp -a "$node_modules_dir" "$out/$node_modules_dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
mkBunCheck =
|
||||||
|
pkgs: nodeModules: name: command:
|
||||||
|
pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "freya-${name}";
|
||||||
|
version = "1";
|
||||||
|
|
||||||
|
src = projectSource;
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
bun
|
||||||
|
nodejs
|
||||||
|
];
|
||||||
|
|
||||||
|
dontConfigure = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
# Root dependencies are read-only and shared across checks.
|
||||||
|
ln -s "${nodeModules}/node_modules" node_modules
|
||||||
|
|
||||||
|
# Workspace node_modules contain relative symlinks back to packages/
|
||||||
|
# and apps/, so copy just those symlink entries into this source tree.
|
||||||
|
for node_modules_dir in "${nodeModules}"/apps/*/node_modules "${nodeModules}"/packages/*/node_modules; do
|
||||||
|
if [ -d "$node_modules_dir" ]; then
|
||||||
|
relative_path="''${node_modules_dir#"${nodeModules}/"}"
|
||||||
|
mkdir -p "$relative_path"
|
||||||
|
cp -a "$node_modules_dir/." "$relative_path/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
${command}
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
touch "$out/${name}"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
apps = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor.${system};
|
||||||
|
in
|
||||||
|
mkBunApps (mkBunScriptCommands pkgs appScripts)
|
||||||
|
);
|
||||||
|
|
||||||
|
checks = lib.genAttrs checkSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor.${system};
|
||||||
|
nodeModules = mkBunNodeModules system pkgs;
|
||||||
|
in
|
||||||
|
lib.mapAttrs (mkBunCheck pkgs nodeModules) checkCommands
|
||||||
|
);
|
||||||
|
|
||||||
|
devShells = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor.${system};
|
||||||
|
bunScriptCommands = lib.attrValues (mkBunScriptCommands pkgs shellScripts);
|
||||||
|
commonPackages = with pkgs; [
|
||||||
|
bun
|
||||||
|
eas-cli
|
||||||
|
git
|
||||||
|
gh
|
||||||
|
gnumake
|
||||||
|
nixfmt
|
||||||
|
nodejs
|
||||||
|
openssl
|
||||||
|
pkg-config
|
||||||
|
postgresql
|
||||||
|
python3
|
||||||
|
watchman
|
||||||
|
];
|
||||||
|
linuxPackages = with pkgs; [
|
||||||
|
gcc
|
||||||
|
inotify-tools
|
||||||
|
tailscale
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages =
|
||||||
|
commonPackages ++ bunScriptCommands ++ pkgs.lib.optionals pkgs.stdenv.isLinux linuxPackages;
|
||||||
|
|
||||||
|
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
formatter = forEachSystem (system: pkgsFor.${system}.nixfmt);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
|
|
||||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
||||||
|
|
||||||
@@ -145,6 +145,16 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error("Timed out waiting for condition")
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TESTS
|
// TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -807,28 +817,35 @@ describe("FeedEngine", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("TTL resets after reactive update", async () => {
|
test("TTL resets after reactive update", async () => {
|
||||||
|
let now = 1_000
|
||||||
|
const nowSpy = spyOn(Date, "now").mockImplementation(() => now)
|
||||||
const location = createLocationSource()
|
const location = createLocationSource()
|
||||||
const weather = createWeatherSource()
|
const weather = createWeatherSource()
|
||||||
|
|
||||||
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
|
const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)
|
||||||
|
|
||||||
engine.start()
|
try {
|
||||||
|
engine.start()
|
||||||
|
|
||||||
// Initial reactive update
|
// Initial reactive update
|
||||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(engine.lastFeed()).not.toBeNull()
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
// Wait 70ms (total 120ms from first update, past original TTL)
|
// Move past the original TTL, then trigger another update to reset it.
|
||||||
// but trigger another update at 50ms to reset TTL
|
now += 120
|
||||||
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
|
location.simulateUpdate({ lat: 52.0, lng: -0.2 })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
// Should still be cached because TTL was reset by second update
|
// Should still be cached because TTL was reset by second update.
|
||||||
expect(engine.lastFeed()).not.toBeNull()
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
engine.stop()
|
engine.stop()
|
||||||
|
} finally {
|
||||||
|
engine.stop()
|
||||||
|
nowSpy.mockRestore()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cacheTtlMs is configurable", async () => {
|
test("cacheTtlMs is configurable", async () => {
|
||||||
@@ -869,17 +886,21 @@ describe("FeedEngine", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
|
const engine = new FeedEngine({ cacheTtlMs: 20 }).register(source)
|
||||||
engine.start()
|
await engine.refresh()
|
||||||
|
|
||||||
// Wait for two TTL intervals to elapse
|
expect(fetchCount).toBe(1)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
|
||||||
|
|
||||||
// Should have auto-refreshed at least twice
|
try {
|
||||||
expect(fetchCount).toBeGreaterThanOrEqual(2)
|
engine.start()
|
||||||
expect(engine.lastFeed()).not.toBeNull()
|
|
||||||
|
|
||||||
engine.stop()
|
await waitForCondition(() => fetchCount >= 2)
|
||||||
|
|
||||||
|
expect(fetchCount).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(engine.lastFeed()).not.toBeNull()
|
||||||
|
} finally {
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stop cancels periodic refresh", async () => {
|
test("stop cancels periodic refresh", async () => {
|
||||||
@@ -935,28 +956,25 @@ describe("FeedEngine", () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = new FeedEngine({ cacheTtlMs: 100 })
|
const engine = new FeedEngine({ cacheTtlMs: 10_000 })
|
||||||
.register(location)
|
.register(location)
|
||||||
.register(countingWeather)
|
.register(countingWeather)
|
||||||
|
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
|
||||||
|
|
||||||
engine.start()
|
try {
|
||||||
|
engine.start()
|
||||||
|
|
||||||
// At 40ms, push a reactive update — this resets the timer
|
const countBeforeUpdate = fetchCount
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
||||||
const countBeforeUpdate = fetchCount
|
await waitForCondition(() => fetchCount > countBeforeUpdate && engine.lastFeed() !== null)
|
||||||
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
||||||
|
|
||||||
// Reactive update triggered a fetch
|
// Reactive updates refresh the cache and reset the pending periodic timer.
|
||||||
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
|
expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
|
||||||
const countAfterUpdate = fetchCount
|
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
// At 100ms from start (60ms after reactive update), the original
|
engine.stop()
|
||||||
// timer would have fired, but it was reset. No extra fetch yet.
|
clearTimeoutSpy.mockRestore()
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
}
|
||||||
expect(fetchCount).toBe(countAfterUpdate)
|
|
||||||
|
|
||||||
engine.stop()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user