Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/VOUCHED.td
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ kikaraage gogogogo!
BlockedPath macOS terminal report #253

Chris79OG WSL work #139

marcinkardas macOS bug reports and PR #288
4 changes: 2 additions & 2 deletions .github/workflows/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- Pages deploys from `main` and `dev` so the site can show both stable and dev-channel release info.
- PRs to `dev` must not build release artifacts. Only `push`/tag/manual runs build, publish, or upload release assets.
- `package.json` and `packages/howcode/package.json` do not have to match. Root tracks app artifacts; `packages/howcode` tracks the npm launcher only.
- The launcher should keep picking up current `main`/`dev` channel assets without an npm publish unless launcher code changes.
- Channel releases must keep stable `channel-main` and `channel-dev` assets so existing launchers update without an npm publish.
- Root app and `packages/howcode` launcher versions are independent; do not add a workflow check that forces them to match.
100 changes: 93 additions & 7 deletions .github/workflows/release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.3.14
no-cache: true

- uses: actions/setup-node@v6
Expand All @@ -56,8 +56,41 @@ jobs:
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
architecture=$(dpkg --print-architecture)
codename=$(. /etc/os-release && printf '%s' "$VERSION_CODENAME")
sources_file=$(mktemp)

case "$architecture" in
amd64)
cat >"$sources_file" <<EOF
deb [arch=amd64] https://archive.ubuntu.com/ubuntu $codename main universe
deb [arch=amd64] https://archive.ubuntu.com/ubuntu $codename-updates main universe
deb [arch=amd64] https://security.ubuntu.com/ubuntu $codename-security main universe
EOF
;;
arm64)
cat >"$sources_file" <<EOF
deb [arch=arm64] https://ports.ubuntu.com/ubuntu-ports $codename main universe
deb [arch=arm64] https://ports.ubuntu.com/ubuntu-ports $codename-updates main universe
deb [arch=arm64] https://ports.ubuntu.com/ubuntu-ports $codename-security main universe
EOF
;;
*)
echo "Unsupported Linux build architecture: $architecture" >&2
exit 1
;;
esac

apt_options=(
-o "Dir::Etc::sourcelist=$sources_file"
-o "Dir::Etc::sourceparts=-"
-o "Acquire::ForceIPv4=true"
-o "Acquire::Retries=2"
-o "Acquire::https::Timeout=20"
)

sudo timeout 180 apt-get "${apt_options[@]}" update
sudo timeout 180 apt-get "${apt_options[@]}" install -y --no-install-recommends \
build-essential \
cmake \
pkg-config \
Expand Down Expand Up @@ -118,7 +151,8 @@ jobs:

- name: Build launcher archives
env:
HOWCODE_RELEASE_ASSET_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_type == 'tag' && github.ref_name || format('channel-{0}', github.ref_name) }}
HOWCODE_RELEASE_CHANNEL: ${{ github.ref_type == 'tag' && 'main' || (github.ref_name == 'dev' && 'dev' || 'main') }}
HOWCODE_RELEASE_ASSET_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_type == 'tag' && github.ref_name || (github.ref_name == 'dev' && 'channel-dev' || 'channel-main') }}
run: bun run build:launcher-artifacts

- name: Upload dev artifacts
Expand Down Expand Up @@ -239,6 +273,11 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
no-cache: true

- name: Download built artifacts
uses: actions/download-artifact@v8
with:
Expand All @@ -249,6 +288,11 @@ jobs:
- name: List channel release assets
run: find release-assets -maxdepth 2 -type f \( -name 'stable-*-update.json' -o -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.exe' -o -name '*.zip' \) -print | sort

- name: Validate channel update manifests
env:
HOWCODE_RELEASE_CHANNEL: ${{ github.ref_name }}
run: bun run validate:release-assets release-assets

- name: Prepare channel release notes
env:
CHANNEL: ${{ github.ref_name }}
Expand Down Expand Up @@ -290,12 +334,44 @@ jobs:
release_title=$(cat channel-release-title.txt)

if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
mkdir -p previous-manifests
gh release download "$RELEASE_TAG" --pattern 'stable-*-update.json' --dir previous-manifests || true

declare -A keep_assets=()
for asset in "${assets[@]}"; do
keep_assets["$(basename "$asset")"]=1
done
while IFS= read -r previous_archive; do
if [ -n "$previous_archive" ]; then
keep_assets["$previous_archive"]=1
fi
done < <(node - <<'NODE'
const fs = require('node:fs')
const path = require('node:path')
for (const name of fs.readdirSync('previous-manifests')) {
try {
const metadata = JSON.parse(fs.readFileSync(path.join('previous-manifests', name), 'utf8'))
if (typeof metadata.assetUrl === 'string') {
console.log(path.basename(new URL(metadata.assetUrl).pathname))
}
} catch {}
}
NODE
)

mapfile -d '' payload_assets < <(find release-assets -type f ! -name 'stable-*-update.json' \( -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.exe' -o -name '*.zip' \) -print0 | sort -z)
mapfile -d '' manifest_assets < <(find release-assets -type f -name 'stable-*-update.json' -print0 | sort -z)
gh release upload "$RELEASE_TAG" "${payload_assets[@]}" --clobber
# Per-target manifests commit last: every advertised immutable archive now exists.
gh release upload "$RELEASE_TAG" "${manifest_assets[@]}" --clobber
gh release edit "$RELEASE_TAG" --title "$release_title" --notes-file channel-release-notes.md --target "$GITHUB_SHA" "${release_flags[@]}"

mapfile -t old_assets < <(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name | select(test("^(stable-.*-update\\.json|howcode-[^-]+-[^-]+\\.tar\\.gz|archive-howcode-[^-]+-[^-]+-[a-f0-9]{64}\\.tar\\.gz|.*\\.AppImage|.*\\.exe|.*\\.zip)$"))')
for old_asset in "${old_assets[@]}"; do
gh release delete-asset "$RELEASE_TAG" "$old_asset" --yes
if [ -z "${keep_assets[$old_asset]+x}" ]; then
gh release delete-asset "$RELEASE_TAG" "$old_asset" --yes
fi
done
gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber
gh release edit "$RELEASE_TAG" --title "$release_title" --notes-file channel-release-notes.md --target "$GITHUB_SHA" "${release_flags[@]}"
else
gh release create "$RELEASE_TAG" "${assets[@]}" --title "$release_title" --target "$GITHUB_SHA" --notes-file channel-release-notes.md "${release_flags[@]}"
fi
Expand All @@ -309,6 +385,11 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
no-cache: true

- name: Download built artifacts
uses: actions/download-artifact@v8
with:
Expand All @@ -319,6 +400,11 @@ jobs:
- name: List release assets
run: find release-assets -maxdepth 2 -type f \( -name 'stable-*-update.json' -o -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.exe' -o -name '*.zip' \) -print | sort

- name: Validate versioned update manifests
env:
HOWCODE_RELEASE_CHANNEL: main
run: bun run validate:release-assets release-assets

- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
Expand Down
98 changes: 98 additions & 0 deletions .pi/skills/effect/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
name: effect
description: |
Opinionated guide for building production TypeScript applications with Effect v4. Use when implementing Effect workflows, services, layers, schemas, configuration, schedules, caches, streams, HTTP clients, or tests.
license: MIT
compatibility: Requires Effect v4. Examples are reviewed against the version documented in this repository.
---

# Effect

Use current Effect v4 APIs and the production defaults in this skill. Established project conventions still take precedence unless the task is explicitly changing them.

## Source Rule

Check these before guessing:

- the nearest `AGENTS.md` and any project-local Effect practices doc
- the project-pinned `effect` package source and version
- current upstream Effect source when the installed package does not answer the question

## Branch Chooser

Read only the branch references that match the task.

- Data models, schemas, brands, variants, optional keys, or decoders: read `references/SCHEMA.md`.
- Services, module surfaces, layers, runtime wiring, errors, `Effect.fn`, or test services: read `references/SERVICES_LAYERS.md`.
- Runtime config, env variables, `ConfigProvider`, or `layerConfig`: read `references/CONFIG.md`.
- Retry, repeat, polling, backoff, jitter, rate-limit-aware policies, or pass loops: read `references/SCHEDULING.md`.
- Memoization, per-key TTL caches, deduplicating concurrent lookups, or request batching: read `references/CACHING.md`.
- Streams, event sources, async iterables, queues/pubsubs, pagination, backpressure, or stream consumers: read `references/STREAMS.md`.
- Outgoing HTTP calls, Effect HttpClient, status handling, or HTTP rate limiting: read `references/HTTP_CLIENTS.md`.
- Effect tests, time, sleeps, concurrency synchronization, or fakes: read `references/TESTING.md`.

If a task spans several branches, read all matching files before editing.

## Core Defaults

- Compose workflows with `Effect.gen(function* () { ... })`.
- Define public service methods and non-trivial internal service methods with `Effect.fn("Domain.operation")`.
- Use `Effect.fnUntraced` only for internal helpers where stack-frame/span metadata is intentionally unnecessary.
- Prefer `Context.Service` for application services when the codebase has not standardized on another current service-tag style.
- Build real service implementations with `Layer.effect(Service, Effect.gen(...))` and return `Service.of({ ... })`.
- Model records with `Schema.Struct(...)` plus a same-name `interface`.
- Model typed Effect errors with `Schema.TaggedError`.
- Read runtime config through `Config`, not direct `process.env` access in application logic.
- Use `Schedule` for retry, repeat, polling, pacing, and backoff policies.
- Use `Stream` for effectful sources that emit many values over time and need pull, backpressure, interruption, or transformation.
- Prefer Effect HTTP client modules for outgoing HTTP in Effect applications when their typed errors, layers, and client transforms are useful.
- Prefer Effect-aware tests, explicit layers, and deterministic synchronization over sleeps.
- Prefer decoders and `schema.makeEffect(...)` at untrusted boundaries; reserve throwing `schema.make(...)` for trusted construction, and never use casts to skip validation.

## Quick Selection Guide

- Ordinary object record: `Schema.Struct(...)` plus same-name `interface`.
- Scalar ID/value object: constrained branded schema.
- Internal workflow decision or state: `Data.TaggedEnum<...>` plus `Data.taggedEnum<...>()` constructors and exhaustive `$match`.
- Reusable boundary-crossing tagged variant: `Schema.TaggedStruct(...)` plus same-name `interface`.
- Boundary-crossing tagged union: `Schema.TaggedUnion(...)` with `.cases`, `.guards`, and `.match`.
- External/custom discriminator such as `type`: `Schema.Struct({ type: Schema.tag("variant"), ... })` plus `Schema.toTaggedUnion("type")` when union helpers are needed.
- Expected typed failure: `Schema.TaggedError`.
- Unknown boundary payload: `Schema.decodeUnknownEffect(...)`.
- Service boundary: `Context.Service<Service, Interface>()(...)` plus `Layer.effect(...)` plus `Service.of(...)`.
- Public or non-trivial internal service method: `Effect.fn("Domain.operation")`.
- Runtime configuration: `Config` recipes read in layers; override with `ConfigProvider` in tests.
- Event source: `Stream` consumed with `Stream.runForEach(...)` and forked with `Effect.forkScoped` in the owning layer.
- Queue-backed event source: `Queue` for the producer boundary, `Stream.fromQueue(...)` for consumers.
- Broadcast event source: `PubSub` / `Stream.fromPubSub(...)` or `SubscriptionRef` for latest-value state.
- Polling worker: `runPass().pipe(Effect.repeat(Schedule.spaced(...)))`, with typed pass failures handled before repeat.
- Retry transient operation: `Effect.retry(...)` / `Effect.retryOrElse(...)` with a bounded `Schedule`.
- Keyed lookup cache with TTL and concurrent-lookup dedupe: prefer `Cache.make(...)` / exit-aware `Cache.makeWith(...)` when their lifecycle and eviction model fit.
- Memoize a single effect result: `Effect.cached(...)` / `Effect.cachedWithTTL(...)`.
- Batch N keys into one backend call (only when a real batch endpoint exists): `Effect.request(...)` + `RequestResolver`.
- HTTP request in an Effect application: prefer Effect `HttpClient` plus request/response schema decoding.
- HTTP transient retry: `HttpClient.retryTransient(...)`.
- Time-sensitive test: `TestClock`, not real sleeping.
- Concurrent/background test synchronization: `Deferred`, `Queue`, `Latch`, `Ref`, or explicit test hooks.

## Boundary Rules

- Keep HTTP handlers thin: decode input, read context, call services, map typed errors to transport responses.
- Keep business rules in services or domain functions, not transport handlers.
- Wrap HTTP clients, SDKs, CLIs, and external integrations in named effects at adapter boundaries.
- Decode persisted rows with Schema or SQL-specific helpers when values are not trivially trusted.
- Keep provider/network calls outside authoritative database transactions.
- Catch or retry only when the current boundary has a truthful response.
- Retry only when the operation has proven idempotency.
- Let exhausted failures remain visible unless the boundary has a real fallback.

## Do Nots

- Do not use `as any`, non-null assertions, or unchecked casts to silence Effect typing problems.
- Do not introduce `Schema.Class` or `Schema.TaggedClass` as default app data-modeling patterns.
- Do not hand-roll `_tag` error classes when `Schema.TaggedError` fits.
- Do not use cause-level recovery when typed-error recovery is enough.
- Do not use `Layer.mergeAll(...)` or `provideMerge(...)` as blind make-it-compile tools.
- Do not hide required application authority, credentials, persistence, transports, or external services behind `Context.Reference` defaults.
- Do not add arbitrary `Effect.sleep(...)` to tests when a deterministic synchronization primitive is available.
- Do not hand-roll Map/TTL/prune caches or in-flight dedupe when `effect/Cache` fits.
76 changes: 76 additions & 0 deletions .pi/skills/effect/references/CACHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Caching, Memoization, And Request Dedupe

Use this when memoizing expensive lookups, caching per-key results with TTL, deduplicating concurrent identical calls, or considering request batching.

Prefer `effect/Cache` over a `Map` + timestamp + prune-loop cache when its keyed memoization, TTL, capacity, lifecycle, and eviction semantics fit.

## Core Rules

- `Cache.make({ capacity, lookup, timeToLive })` caches per-key lookups with one fixed TTL for all entries.
- `Cache.makeWith(lookup, { capacity, timeToLive(exit, key) })` computes TTL per entry from the lookup's `Exit` — the tool for "cache successes, not failures".
- Concurrent `Cache.get` calls for the same missing key share one pending lookup — dedupe is built in; do not add your own in-flight tracking.
- `capacity` is required and bounds the cache; stop writing manual prune/evict loops.
- Return a zero TTL (`0` or `"0 millis"`) from `timeToLive` to avoid caching transient failures or degraded fallbacks without failing the caller. A short negative-cache TTL can be appropriate for stable failures such as not-found results.
- `Cache.invalidate(cache, key)` / `Cache.refresh(cache, key)` handle explicit staleness; `Cache.has` checks without triggering a lookup.
- Cache construction is effectful. Build the cache once in the owning layer/scope and share the handle; a cache built per call caches nothing.
- For a single value (no key), use `Effect.cached(effect)` or `Effect.cachedWithTTL(effect, ttl)` instead of a one-key Cache.
- For cached resources that need cleanup (connections, clients), use `ScopedCache`.

## Exit-Aware TTL (cache successes, skip degraded results)

```ts
import { Cache, Duration, Effect, Exit } from "effect"

const makeResolver = Effect.gen(function* () {
const cache = yield* Cache.makeWith(
(channelRef: string) => resolveUncached(channelRef), // never-failing, returns { where, cacheable }
{
capacity: 300,
timeToLive: (exit) =>
Exit.isSuccess(exit) && exit.value.cacheable ? "10 minutes" : Duration.zero,
},
)
return (channelRef: string) =>
Cache.get(cache, channelRef).pipe(Effect.map((resolved) => resolved.where))
})
```

This replaces a hand-rolled `Map<string, { value, expiresAtMs }>` plus prune logic, and upgrades it: repeated rows pointing at the same key during one burst share a single provider call.

## Expensive Client Acquisition Belongs In The Layer, Not The Lookup

A cache cannot fix a lookup that pays a scoped acquisition per call, such as SDK client construction or authentication. Acquire clients once via the owning layer (`Layer.build` inside a `Layer.unwrap(Effect.gen(...))` composition, or a service dependency) so the cached lookup is a plain call:

```ts
// Bad: every cache miss acquires a fresh client
const lookup = (id: string) =>
getRecord(id).pipe(Effect.provide(apiClientLayer(options)))

// Good: client built once for the layer's lifetime; misses are one API call
// Layer.build requires Scope.Scope; acquire this inside the owning layer's scope.
const context = yield* Layer.build(apiClientLayer(options))
const lookup = (id: string) => Context.get(context, ApiClient).getRecord(id)
```

## Request Batching (`Effect.request` + `RequestResolver`)

Batching exists for backends with a real batch endpoint: the resolver receives an array of pending requests and can collapse them into one wire call.

- Use it when the API can answer N keys in one call (SQL `IN (...)`, DataLoader-style endpoints, batch GET).
- Do not reach for it when the backend only has per-item endpoints (most REST provider APIs): a batched resolver still loops one call per entry, so it buys nothing over `Effect.forEach(items, f, { concurrency })` plus `Cache` for dedupe/memoization.
- `RequestResolver.batchN(resolver, n)` bounds batch size; `RequestResolver.makeGrouped` groups requests that must resolve through different targets.

Selection guide:

- Same key requested repeatedly over time → `Cache`.
- Same key requested concurrently in one burst → `Cache` (shared pending lookup).
- Many distinct keys, backend has a batch endpoint → `Effect.request` + `RequestResolver`.
- Many distinct keys, per-item endpoint only → `Effect.forEach(..., { concurrency: n })`, optionally through a `Cache`.

## Do Nots

- Do not hand-roll Map/TTL/prune caches, in-flight dedupe maps, or LRU logic when `Cache` fits.
- Choose failure TTLs by semantics. Skip transient failures and degraded fallbacks by default; bounded negative caching can protect an upstream from repeated stable failures.
- Do not build a cache inside the request handler or per call — hoist it to the owning layer.
- Do not adopt `RequestResolver` batching for per-item REST endpoints just because "batching" sounds faster.
- Do not put scoped client acquisition inside the cache lookup; acquire once in the layer.
Loading