Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ bounded effect

## Applications Built With HowlFrame

* **[HowlBoard](https://github.com/howlcipher/howlboard)** — the canonical
HowlFrame reference application and human-facing AI engineering operations
interface. A full-stack mission-control console for governed autonomous work:
`http_server` compiled to standalone bytecode, `web_app` compiled through the
JavaScript backend, native-store persistence, and no hand-written server or
client code. Live at <https://howlcipher.github.io/howlboard/>. Its
[dogfooding journal](https://github.com/howlcipher/howlboard/blob/main/docs/dogfooding.md)
is the most detailed record of where this language helps and where it gets in
the way.
* [Status API](apps/status_api/README.md) — Proves HTTP serving, deterministic routing, and environment inspection.
* [Log Analyzer](apps/log_analyzer/README.md) — Proves file parsing, deterministic string logic, and graceful capability denial.
* [KV CLI](apps/kv_cli/README.md) — Proves in-memory store functionality and sequential deterministic state.
Expand Down Expand Up @@ -281,7 +290,20 @@ go run howlframe.go -o build examples/wasm_math.howl

### Reference Application

[HowlFrame Repo Analyst](examples/repo_analyst/README.md) is a deterministic, five-module application that compiles to HowlFrame bytecode and analyzes repositories without generated Go or JavaScript. Its dogfooding tests prove both the unchanged default instruction ceiling and a larger finite budget explicitly authorized by the trusted runner.
[HowlBoard](https://github.com/howlcipher/howlboard) is the canonical reference
application: a full-stack mission-control interface for governed autonomous
engineering work, with both tiers written in HowlFrame. It is the largest
program built on this toolchain and the one that most exercises it — the
`store_keys` primitive, heterogeneous dict records, fail-closed HTTP handlers,
and several `web_app` codegen fixes all exist because building it required
them.

[HowlFrame Repo Analyst](examples/repo_analyst/README.md) remains the reference
for the in-repository bytecode path: a deterministic, five-module application
that compiles to HowlFrame bytecode and analyzes repositories without generated
Go or JavaScript. Its dogfooding tests prove both the unchanged default
instruction ceiling and a larger finite budget explicitly authorized by the
trusted runner.

## Common Language Features

Expand Down
30 changes: 30 additions & 0 deletions apps/task_api/DEVELOPMENT_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ Phase 3 dogfooding: a genuine stateful HTTP CRUD service, running as
standalone bytecode, exercised across multiple independent HTTP requests
against one long-lived server process.

## Corrections (recorded during the HowlBoard dogfood pass)

Three claims below were accurate when written and are no longer true. They are
left in place for the record, with the correction stated here rather than
silently edited into the original text.

* **"No opcode exposes method, query string, headers, or path segments" — partly
stale.** `OpHttpReqMethod` exists and `(req_method req)` returns the HTTP
method; HowlBoard uses it for OPTIONS preflight in every route. Query strings,
headers and path segments remain genuinely unavailable, and `req_header` is
documented in the app-development skill despite having no opcode.

* **"`defun` return-type annotations compile but crash at runtime" and "there is
currently no working path to a `defun` that returns a dict" — stale.** The
supported form is the `type_hint` annotation, not a positional type symbol:
`(defun make_rec (id) (type_hint return "dict") ...)` compiles and returns a
dict that `map_get` consumes correctly at the call site. `type_hint` is
compiled away as a pure annotation. HowlBoard's backend and interface are both
decomposed into dict-returning helpers on this basis.

* **"An unhandled panic inside a route handler is silently swallowed as a
successful response" — fixed.** A handler that fails before writing now
returns 500 carrying the structured `VMError` JSON with its code preserved,
logged to the VM error stream rather than process stdout. See
`TestHTTPHandlerFailuresFailClosed`.

Separately, the note that `store_keys`-style enumeration is "adequate friction,
not a blocker" no longer applies: `store_keys` exists, returns sorted keys, and
removes the `next_id` scan workaround entirely.

## What worked well

* **State sharing across HTTP requests.** Every `route` handler runs in a
Expand Down
18 changes: 12 additions & 6 deletions apps/task_api/task_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,13 +399,19 @@ func TestTaskAPICapabilities(t *testing.T) {

t.Run("network only starts but store access is denied", func(t *testing.T) {
startServer(t, srcDir, bcPath, "network")
// Documented runtime finding: an unhandled capability-denied panic
// inside a route handler is swallowed by the VM's own recover, and
// the client observes a 200 with an empty body rather than an
// error status. This subtest asserts that real, current behavior.
// This subtest previously asserted the opposite: that a
// capability-denied panic inside a route handler was swallowed and the
// client saw a 200 with an empty body. That made the platform's core
// safety mechanism indistinguishable from success at the client, and
// the VM now fails closed instead, surfacing the structured VMError
// with its code intact. See TestHTTPHandlerFailuresFailClosed in
// internal/vm for the unit-level regression test.
status, data := doRaw(t, "POST", "/tasks/create", `{"title":"x"}`)
if status != 200 || len(data) != 0 {
t.Errorf("expected silent 200/empty-body on capability-denied store access (documented runtime finding), got %d body=%q", status, data)
if status != 500 {
t.Errorf("expected 500 on capability-denied store access, got %d body=%q", status, data)
}
if !strings.Contains(string(data), "CAPABILITY_DENIED") {
t.Errorf("denial response does not carry its code: %q", data)
}
})

Expand Down
37 changes: 37 additions & 0 deletions change_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
## Unreleased

### Added
* `time_now` in the JavaScript backend. It was supported by the bytecode VM and
the Go backend but rejected as an unknown statement for `web_app` programs, so
a browser interface had no way to read the clock and render relative times.
* `store_keys` construct and `STORE_KEYS` bytecode instruction, returning every
record key in a native store as a sorted list. Go randomizes map iteration, so
enumeration is sorted to keep listing deterministic. Every prior HowlFrame
application (`kv_cli`, `todo_cli`, `task_api`, HowlBoard) had to maintain a
parallel index record that could silently diverge from the records it indexed;
`store_keys` removes that workaround. Requires the `database` capability, and
`filesystem` additionally for `file://` stores.
* Runner-sealed, bounded negative map-state provenance for the internal direct
HFIR experiment. It records completed map mutations and reads by backing-map
identity, proves never-present versus effectively deleted keys, and fails
Expand Down Expand Up @@ -65,6 +75,13 @@
diverge silently.

### Changed
* Dict values may now mix types. Dicts are the language's record literal, and the
VM and native store both carry `map[string]any`, so a record combining strings,
ints, lists, and nested dicts already executed correctly; only the analyzer
rejected it. Heterogeneous dict literals and `map_set` writes now widen the
element type to `any` through the existing `join` helper instead of reporting
`dict value N has type X, want Y`. Key checks, target-kind checks, and list
element homogeneity are unchanged.

* Documented the existing standalone HTTP JSON request composition
(`parse_json ... req.body` with `try_let`), its bounded scope, and the
Expand Down Expand Up @@ -104,6 +121,26 @@
are classified separately and keep compiling unchanged.

### Fixed
* `for` over an expression no longer silently miscompiles in the JavaScript and
Go backends. Both read the iterable's raw node value, which is empty for
anything but a bound symbol, so `(for m (map_get d "missions") ...)` emitted
`for (let m of )` and `for _, m := range {` - invalid output produced with no
diagnostic, in a toolchain whose contract is to fail closed.
* `on_event` now terminates its statement. Automatic semicolon insertion does
not apply before `(`, so any following top-level statement was parsed as a
call of the `addEventListener` result.
* A `web_app`'s top-level statements are wrapped in an async IIFE. They routinely
contain awaited calls, and a classic `<script>` has no top-level await, so
every generated interface failed to parse in the browser. Function
declarations remain at top level so inline handlers can still reach them as
globals.
* Route handlers fail closed. A panic inside an `http_server` route handler wrote
nothing to the `ResponseWriter`, so Go emitted `200` with an empty body and a
denied capability was indistinguishable from a completed request. Handlers that
fail before responding now return `500` with the structured `VMError` JSON
(preserving codes such as `CAPABILITY_DENIED` and `LIMIT_EXCEEDED`), and the
failure is reported on the VM's error stream rather than process stdout. A
handler that already committed a response is left untouched.
* `SPAWN_AGENT` and `TASK` opcodes in the standalone bytecode VM now report a
structured `UNSUPPORTED_CONSTRUCT` runtime error naming the opcode rather than
panicking with `VM_INTERNAL` as an unknown opcode. Both opcodes are emitted
Expand Down
6 changes: 3 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@
<a href="https://howlcipher.github.io/howlboard/" class="drawer-node-card">
<div class="node-card-top">
<span class="node-card-name">HOWLBOARD</span>
<span class="node-card-role" style="color: var(--color-amber);">[EVALUATION SURFACE]</span>
<span class="node-card-role" style="color: var(--color-amber);">[REFERENCE APPLICATION]</span>
</div>
<div class="node-card-desc">Observation console and full-stack deterministic task state machine application.</div>
<div class="node-card-desc">The canonical HowlFrame reference application: mission control for governed autonomous engineering work, with both tiers written in HowlFrame.</div>
</a>

<a href="https://howlcipher.github.io/howlnotes/" class="drawer-node-card">
Expand Down Expand Up @@ -404,7 +404,7 @@ <h3 style="font-size: 1rem; color: var(--color-amber); margin-bottom: 0.5rem;">H
<article class="tech-card" style="background: rgba(10, 14, 20, 0.6); border: 1px solid var(--border-color); padding: 1.25rem;">
<h3 style="font-size: 1rem; color: var(--color-green); margin-bottom: 0.5rem;">How does HowlFrame integrate with the Howl Ecosystem?</h3>
<p style="font-size: 0.875rem; line-height: 1.5; color: var(--text-muted);">
HowlFrame serves as the core capability runtime beneath <a href="https://howlcipher.github.io/howlplane/" style="color: var(--color-cyan); text-decoration: underline;">HowlPlane</a> (orchestration and control plane) and <a href="https://howlcipher.github.io/howlchangeops/" style="color: var(--color-cyan); text-decoration: underline;">HowlChangeOps</a> (governed mutation controller). Applications like <a href="https://howlcipher.github.io/howlboard/" style="color: var(--color-cyan); text-decoration: underline;">HowlBoard</a> and <a href="https://howlcipher.github.io/howlnotes/" style="color: var(--color-cyan); text-decoration: underline;">HowlNotes</a> dogfood the bytecode VM and native store to verify real-world resilience across state transitions.
HowlFrame serves as the core capability runtime beneath <a href="https://howlcipher.github.io/howlplane/" style="color: var(--color-cyan); text-decoration: underline;">HowlPlane</a> (orchestration and control plane) and <a href="https://howlcipher.github.io/howlchangeops/" style="color: var(--color-cyan); text-decoration: underline;">HowlChangeOps</a> (governed mutation controller). <a href="https://howlcipher.github.io/howlboard/" style="color: var(--color-cyan); text-decoration: underline;">HowlBoard</a> is the canonical reference application and human-facing operations interface, with <a href="https://howlcipher.github.io/howlnotes/" style="color: var(--color-cyan); text-decoration: underline;">HowlNotes</a> alongside it; both dogfood the bytecode VM and native store to verify real-world resilience across state transitions.
</p>
</article>
</div>
Expand Down
1 change: 1 addition & 0 deletions docs/reference/bytecode_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
| `STDERR` | | 1 | 0 | | Prints a value to standard error |
| `STORE_DELETE` | string | 1 | 0 | database | Deletes a structured record by key |
| `STORE_GET` | string | 1 | 1 | database | Fetches a structured record by key |
| `STORE_KEYS` | string | 0 | 1 | database | Returns every record key in a store, sorted |
| `STORE_OPEN` | string, string | 0 | 0 | database | Creates or attaches a named in-memory store handle |
| `STORE_PUT` | string | 2 | 0 | database | Upserts a structured record by key |
| `STORE_VAR` | string | 1 | 0 | | Pops a value and stores it in a new variable |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/construct_coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
| `stderr` | Yes | None | No | No | - | AUTHORITATIVE |
| `store_delete` | Yes | database | No | No | - | AUTHORITATIVE |
| `store_get` | Yes | database | No | No | - | AUTHORITATIVE |
| `store_keys` | Yes | database | No | No | - | AUTHORITATIVE |
| `store_open` | Yes | database | No | No | - | AUTHORITATIVE |
| `store_put` | Yes | database | No | No | - | AUTHORITATIVE |
| `str_join` | Yes | None | No | No | - | AUTHORITATIVE |
Expand Down
7 changes: 5 additions & 2 deletions internal/backend/gogen/gogen.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,12 +775,15 @@ func EmitGoIR(ir *ir.IRNode, reqVar string, depth int) string {
return fmt.Sprintf(" %s(%s)", funcName, strings.Join(args, ", "))
case "for":
itemNode := ir.Kids[0].Value
listNode := ir.Kids[1].Value
// The iterable may be any expression, not only a bound symbol. Reading
// .Value directly yielded "" for a list-valued expression such as
// (for m (map_get d "missions") ...), emitting "for _, m := range {".
listExpr := generateExpression(ir.Kids[1], reqVar, depth+1)
bodyCode := generateStatement(ir.Kids[2], reqVar, depth+1)
return fmt.Sprintf(` for _, %s := range %s {
_ = %s
%s
}`, itemNode, listNode, itemNode, bodyCode)
}`, itemNode, listExpr, itemNode, bodyCode)
case "return":
return fmt.Sprintf(" return %s", generateStatementRaw(ir.Kids[0], reqVar, depth+1))
case "if":
Expand Down
30 changes: 25 additions & 5 deletions internal/backend/javascript/javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,12 @@ func EmitJSIR(ir *ir.IRNode, reqVar string, depth int) string {
return fmt.Sprintf("{\n\tlet %s;\n\tlet %s = null;\n\ttry {\n\t\t%s = %s;\n\t} catch (e) {\n\t\t%s = e;\n\t}\n\tif (%s !== null) {\n\t\t%s\n\t} else {\n\t\t%s\n\t}\n}", varName, errVar, varName, valStr, errVar, errVar, catchBodyCode, successBodyCode)
case "for":
itemNode := ir.Kids[0].Value
listNode := ir.Kids[1].Value
// The iterable may be any expression, not only a bound symbol. Reading
// .Value directly yielded "" for a list-valued expression such as
// (for m (map_get d "missions") ...), emitting "for (let m of )".
listExpr := generateJSExpression(ir.Kids[1], reqVar, depth+1)
bodyCode := generateJSStatement(ir.Kids[2], reqVar, depth+1)
return fmt.Sprintf("for (let %s of %s) {\n%s\n}", itemNode, listNode, bodyCode)
return fmt.Sprintf("for (let %s of %s) {\n%s\n}", itemNode, listExpr, bodyCode)
case "call":
funcName := sanitizeJSName(ir.Kids[0].Value)
var args []string
Expand Down Expand Up @@ -263,6 +266,10 @@ func EmitJSIR(ir *ir.IRNode, reqVar string, depth int) string {
case "is_nil":
valStr := generateJSStatementRaw(ir.Kids[0], reqVar, depth+1)
return fmt.Sprintf("(%s === null || %s === undefined)", valStr, valStr)
case "time_now":
// Unix seconds, matching the bytecode and Go backends. A web_app that
// renders relative timestamps has no other way to read the clock.
return "Math.floor(Date.now() / 1000)"
case "list":
var items []string
for _, kid := range ir.Kids {
Expand Down Expand Up @@ -369,7 +376,14 @@ func GenerateJSCode(node *ast.Node) (string, string) {
appCode += generateJSStatement(handlerNode, "", 0) + "\n"
}

code := funcsCode + appCode
// Top-level statements are the application's bootstrap and routinely
// contain awaited calls. A classic <script> has no top-level await, so they
// run inside an async IIFE. Function declarations stay at top level, which
// keeps them reachable as globals for inline event handlers.
code := funcsCode
if strings.TrimSpace(appCode) != "" {
code += fmt.Sprintf(";(async () => {\n%s\n})();\n", appCode)
}

if testCode != "" {
testCode = "const test = require('node:test');\n" +
Expand Down Expand Up @@ -419,7 +433,10 @@ func generateJSStatementRaw(node *ast.Node, reqVar string, depth int) string {
if ir, ok := ir.LowerShared(node); ok {
return EmitJSIR(ir, reqVar, depth)
}
if head == "dom_query" {
if head == "time_now" {
// Unix seconds, matching the bytecode VM and the Go backend.
return "Math.floor(Date.now() / 1000)"
} else if head == "dom_query" {
if len(node.Children) != 2 {
// ast.ReportError("dom_query expects (dom_query selector)", node.Line, node.Column)
}
Expand All @@ -441,7 +458,10 @@ func generateJSStatementRaw(node *ast.Node, reqVar string, depth int) string {
argName = args[0].Value
}
body := generateJSStatement(lambda.Children[2], reqVar, depth+1)
return fmt.Sprintf("%s.addEventListener(%s, async (%s) => {\n%s\n})", el, event, argName, body)
// The trailing semicolon is required: without it a following statement
// that begins with "(" is parsed as a call of this expression's result
// rather than as its own statement.
return fmt.Sprintf("%s.addEventListener(%s, async (%s) => {\n%s\n});", el, event, argName, body)
} else if head == "set_html" {
if len(node.Children) != 3 {
// ast.ReportError("set_html expects (set_html el val)", node.Line, node.Column)
Expand Down
Loading
Loading