Compare commits

..

1 Commits

Author SHA1 Message Date
3a4e393e7a fix(admin-dashboard): redirect to login on session fetch failure
Wrap the session check in beforeLoad with a try/catch so
network errors, 404s, and other failures redirect to the
login page instead of showing an error boundary.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-24 21:10:31 +00:00
10 changed files with 53 additions and 244 deletions

View File

@@ -11,7 +11,7 @@
"dockerfile": "Dockerfile"
},
"postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
// Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects.
"features": {

View File

@@ -17,23 +17,3 @@ services:
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
aelis-backend:
name: Aelis Backend
description: Hono API server for aelis-backend (port 3000)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
cd apps/aelis-backend && bun run dev
admin-dashboard:
name: Admin Dashboard
description: Vite dev server for admin-dashboard (port 5174)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 5174 --name "Admin Dashboard" --protocol http
cd apps/admin-dashboard && bun run dev --host

View File

@@ -46,7 +46,7 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
type: "json_schema" as const,
jsonSchema: {
name: "enhancement_result",
strict: false,
strict: true,
schema: enhancementResultJsonSchema,
},
},

View File

@@ -166,12 +166,11 @@ describe("schema sync", () => {
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values
const slotValueSchema =
const slotValueTypes =
enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties
expect(slotValueSchema.anyOf).toEqual([
{ type: "string" },
{ type: "null" },
])
.additionalProperties.type
expect(slotValueTypes).toContain("string")
expect(slotValueTypes).toContain("null")
expect(slotValueTypes).not.toContain("number")
})
})

View File

@@ -31,7 +31,7 @@ export const enhancementResultJsonSchema = {
additionalProperties: {
type: "object",
additionalProperties: {
anyOf: [{ type: "string" }, { type: "null" }],
type: ["string", "null"],
},
},
},

View File

@@ -32,7 +32,7 @@ export interface CurrentWeatherFeedItem extends FeedItem<
CurrentWeatherData
> {}
export type HourlyWeatherEntry = {
export type HourlyWeatherData = {
forecastTime: Date
conditionCode: ConditionCode
daylight: boolean
@@ -48,16 +48,12 @@ export type HourlyWeatherEntry = {
windSpeed: number
}
export type HourlyWeatherData = {
hours: HourlyWeatherEntry[]
}
export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly,
HourlyWeatherData
> {}
export type DailyWeatherEntry = {
export type DailyWeatherData = {
forecastDate: Date
conditionCode: ConditionCode
maxUvIndex: number
@@ -71,10 +67,6 @@ export type DailyWeatherEntry = {
temperatureMin: number
}
export type DailyWeatherData = {
days: DailyWeatherEntry[]
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily,
DailyWeatherData

View File

@@ -8,10 +8,8 @@ export {
type CurrentWeatherData,
type HourlyWeatherFeedItem,
type HourlyWeatherData,
type HourlyWeatherEntry,
type DailyWeatherFeedItem,
type DailyWeatherData,
type DailyWeatherEntry,
type WeatherAlertFeedItem,
type WeatherAlertData,
} from "./feed-items"

View File

@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"
import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source"
@@ -131,125 +131,8 @@ describe("WeatherSource", () => {
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(hourlyItems.length).toBe(1)
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3)
expect(dailyItems.length).toBe(1)
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
})
test("produces a single hourly item with hours array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItems.length).toBe(1)
const hourlyData = hourlyItems[0]!.data as HourlyWeatherData
expect(Array.isArray(hourlyData.hours)).toBe(true)
expect(hourlyData.hours.length).toBeGreaterThan(0)
expect(hourlyData.hours.length).toBeLessThanOrEqual(12)
})
test("averages urgency across hours with mixed conditions", async () => {
const mildHour: HourlyForecast = {
forecastStart: "2026-01-17T01:00:00Z",
conditionCode: "Clear",
daylight: true,
humidity: 0.5,
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
pressure: 1013,
snowfallIntensity: 0,
temperature: 20,
temperatureApparent: 20,
temperatureDewPoint: 10,
uvIndex: 3,
visibility: 20000,
windDirection: 180,
windGust: 10,
windSpeed: 5,
}
const severeHour: HourlyForecast = {
...mildHour,
forecastStart: "2026-01-17T02:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastHourly: { hours: [mildHour, severeHour] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItem = items.find((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItem).toBeDefined()
// Mild urgency = 0.3, severe urgency = 0.6, average = 0.45
expect(hourlyItem!.signals!.urgency).toBeCloseTo(0.45, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
})
test("produces a single daily item with days array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItems.length).toBe(1)
const dailyData = dailyItems[0]!.data as DailyWeatherData
expect(Array.isArray(dailyData.days)).toBe(true)
expect(dailyData.days.length).toBeGreaterThan(0)
expect(dailyData.days.length).toBeLessThanOrEqual(7)
})
test("averages urgency across days with mixed conditions", async () => {
const mildDay: DailyForecast = {
forecastStart: "2026-01-17T00:00:00Z",
forecastEnd: "2026-01-18T00:00:00Z",
conditionCode: "Clear",
maxUvIndex: 3,
moonPhase: "firstQuarter",
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
snowfallAmount: 0,
sunrise: "2026-01-17T07:00:00Z",
sunriseCivil: "2026-01-17T06:30:00Z",
sunriseNautical: "2026-01-17T06:00:00Z",
sunriseAstronomical: "2026-01-17T05:30:00Z",
sunset: "2026-01-17T17:00:00Z",
sunsetCivil: "2026-01-17T17:30:00Z",
sunsetNautical: "2026-01-17T18:00:00Z",
sunsetAstronomical: "2026-01-17T18:30:00Z",
temperatureMax: 15,
temperatureMin: 5,
}
const severeDay: DailyForecast = {
...mildDay,
forecastStart: "2026-01-18T00:00:00Z",
forecastEnd: "2026-01-19T00:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastDaily: { days: [mildDay, severeDay] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItem).toBeDefined()
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2)
})
test("sets timestamp from context.time", async () => {

View File

@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context"
import {
@@ -174,15 +174,21 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
if (response.forecastHourly?.hours) {
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
if (hours.length > 0) {
items.push(createHourlyForecastFeedItem(hours, timestamp, this.units, this.id))
for (let i = 0; i < hours.length; i++) {
const hour = hours[i]
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
}
}
}
if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
if (days.length > 0) {
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id))
for (let i = 0; i < days.length; i++) {
const day = days[i]
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
}
}
}
@@ -317,18 +323,24 @@ function createCurrentWeatherFeedItem(
}
}
function createHourlyForecastFeedItem(
hourlyForecasts: HourlyForecast[],
function createHourlyWeatherFeedItem(
hourly: HourlyForecast,
index: number,
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const hours: HourlyWeatherEntry[] = []
let totalUrgency = 0
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
}
for (const hourly of hourlyForecasts) {
hours.push({
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
conditionCode: hourly.conditionCode,
daylight: hourly.daylight,
@@ -342,43 +354,29 @@ function createHourlyForecastFeedItem(
windDirection: hourly.windDirection,
windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units),
})
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode)
const rel = timeRelevanceForCondition(hourly.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / hours.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-hourly-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: { hours },
},
signals,
}
}
function createDailyForecastFeedItem(
dailyForecasts: DailyForecast[],
function createDailyWeatherFeedItem(
daily: DailyForecast,
index: number,
timestamp: Date,
units: Units,
sourceId: string,
): WeatherFeedItem {
const days: DailyWeatherEntry[] = []
let totalUrgency = 0
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient
const signals: FeedItemSignals = {
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
for (const daily of dailyForecasts) {
days.push({
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex,
@@ -390,27 +388,7 @@ function createDailyForecastFeedItem(
sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units),
})
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
const rel = timeRelevanceForCondition(daily.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / days.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-daily-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: { days },
},
signals,
}
}

View File

@@ -1,21 +0,0 @@
#!/bin/bash
# Tailscale setup script
# Authenticates with Tailscale if TS_AUTH_KEY is set and Tailscale is not already logged in
set -e
if [ -z "$TS_AUTH_KEY" ]; then
echo "TS_AUTH_KEY is not set, skipping Tailscale login."
exit 0
fi
STATUS=$(tailscale status 2>&1 || true)
if echo "$STATUS" | grep -qi "logged out\|stopped"; then
echo "Tailscale is not authenticated. Logging in..."
sudo tailscale up --accept-routes --auth-key="$TS_AUTH_KEY"
echo "Tailscale login complete."
else
echo "Tailscale is already authenticated, skipping."
fi