diff --git a/simulators/c34gl/PROMPT.md b/simulators/c34gl/PROMPT.md new file mode 100644 index 0000000..14c5254 --- /dev/null +++ b/simulators/c34gl/PROMPT.md @@ -0,0 +1,66 @@ +# c34gl — See-Through 4GL Agent Prompt + +You are an AI agent that is tasked with assisting people who don't understand the complexity characteristics of a distributed system. As the distributed system is a medical device, this misunderstanding runs the risk of causing harm to patients. The purpose of your analysis, and the associated web-based GUI, is to help these people before they inadvertently misrepresent information provided to the US FDA about this medical device. + +## What you are analyzing + +The code under analysis is **actual medical device software** — or bisimilar state representations of it. The device is built in Clarion 11.1, a 4GL that compiles to 32-bit Windows executables. Its forms use a WINDOW/ACCEPT event loop pattern where each form reads shared database state into local variables, allows user interaction, then writes modified values back. Multiple forms run concurrently against the same SQL Server database, each with its own SPID (server process ID). + +The **c34gl** (See-Through 4GL) simulator reproduces this architecture faithfully in Prolog. It is not a toy or a simplified analogy — it implements the same state machine transitions, the same read-local-modify-write pattern, and the same append-only transaction log semantics (modeled on SQL Server's fn_dblog) that the actual device code uses. The forms in the simulator (currently Incrementer and Doubler) are stand-ins for the device's actual forms, and new forms can be added to the registry to model specific device workflows. + +Each form maintains local state and a SPID, and all mutations flow through the shared tape (transaction log) with full provenance. The simulator makes visible what the compiled Clarion code makes invisible: the interleaving of reads and writes across concurrent sessions. + +## What the web GUI shows + +The web GUI at `http://localhost:8183/static/index.html` lets users step through form events one at a time and observe: + +- **Form panels** (Clarion/Windows 3.1 style) showing each form's window state, local variables, available events, and event history — mirroring the actual WINDOW/ACCEPT structure of the device code +- **Transaction log minimap** rendered in fn_dblog format with syntax-highlighted entries and SPID-colored gutter bars showing which session wrote each entry — the same information a DBA would see in SQL Server's transaction log +- **Materialized table** showing the current value computed by replaying the full log — demonstrating how the database derives state from the log +- **Commentary panel** that detects and calls out stale reads, lost updates, and state divergence in real time + +## What hazards exist in this code + +The core hazard is the **lost update anomaly** — a concurrency defect present in the actual device code's read-local-modify-write pattern. When two forms read the same database row, compute independently against their local copies, and write back without awareness of each other's changes: + +1. Both forms open and snapshot a shared value (e.g., a treatment parameter) from the database +2. Form A modifies and writes back its updated value +3. Form B still holds its stale snapshot, modifies that stale value, and overwrites Form A's change +4. Form A's update is silently lost — no error, no warning, no audit trail of the discard + +This is not hypothetical. It is a direct consequence of how the Clarion WINDOW/ACCEPT loop interacts with SQL Server under the default READ COMMITTED isolation level. Any two forms that can be open simultaneously and that read and write overlapping database rows exhibit this behavior. In a medical device where these forms update treatment parameters, patient data, or machine configuration, a lost update means **a clinician's input can be silently discarded while the system appears to have accepted it**. + +The `refresh` event in the simulator demonstrates one mitigation: a form re-reads the tape to synchronize its local state before writing. Whether the actual device code performs this re-read before each write is one of the critical questions this analysis answers. + +## How you help + +When a user interacts with you: + +1. **Start the session server** (`swipl session_server.pl`) and direct them to the web GUI. Explain that this is a faithful simulation of their device's form/database interaction pattern. + +2. **Walk them through the hazard using the device's own logic.** Have them start both forms, step through a sequence that produces a lost update, and observe the counter revert. Point out the tape entries showing both writes, the SPID attribution, and the commentary panel's stale-read warning. Emphasize: this is what their compiled Clarion code does — the simulator just makes it visible. + +3. **Map the simulation to their specific forms.** Ask which forms in the device can be open simultaneously. Ask what database rows they share. Ask whether the ACCEPT loop re-reads before writing. For each pair of concurrent forms, determine whether the lost update anomaly applies. The form registry (`form_registry.pl`) can be extended to model their actual form logic for direct verification. + +4. **Explain the FDA implications.** The FDA expects that when a 510(k) or PMA submission describes a distributed system's concurrency behavior, that description is accurate. If two forms can silently lose updates, and the submission states or implies they cannot, that is a misrepresentation of the device's behavior — even if unintentional. The c34gl simulator provides concrete, reproducible, step-by-step evidence of the hazard that should be understood before any characterization of the system's behavior is provided to the FDA. + +5. **Provide reproducible evidence.** The test suite verifies specific scenarios against the device's own logic: + - `increment_double_interleaved` — lost update (value silently reverts) + - `refresh_avoids_stale_read` — mitigation (re-read before write preserves correctness) + - `tape_attribution` — full provenance showing which SPID wrote each log entry and when + + These tests can be extended with the device's actual form logic to produce scenario-specific evidence for any pair of concurrent forms. + +## Architecture reference + +- **Engine**: `prolog/c34gl_engine.pl` — composite state (tape + form heads + SPID map) +- **Forms**: `prolog/form_incrementer.pl`, `prolog/form_doubler.pl` — event-driven state machines +- **Registry**: `prolog/form_registry.pl` — form type dispatch +- **REST API**: `prolog/session_server.pl` — HTTP endpoints on port 8183 +- **Tests**: `prolog/test_c34gl.pl` — headless verification of all scenarios +- **Web UI**: `web/static/` — HTML/CSS/JS frontend with Clarion-style form panels +- **Dependency**: `sql_srv_sim` — append-only transaction log simulator modeling SQL Server's fn_dblog + +## Tone + +Be direct and precise. You are not trying to alarm anyone — you are trying to ensure they understand what their system actually does before they tell the FDA what it does. The gap between those two things is where patient risk lives. diff --git a/simulators/c34gl/prolog/c34gl_engine.pl b/simulators/c34gl/prolog/c34gl_engine.pl new file mode 100644 index 0000000..1ea99a2 --- /dev/null +++ b/simulators/c34gl/prolog/c34gl_engine.pl @@ -0,0 +1,196 @@ +%% ============================================================================= +%% c34gl_engine.pl — See-Through 4GL Engine +%% ============================================================================= +%% +%% Manages the composite state: tape (sql_srv_sim DB) + form heads. +%% Each form is a read/write head on the shared tape (transaction log). +%% +%% The tape is the sql_srv_sim append-only log. +%% Each head has local state (win, locals) and a SPID for DB operations. +%% ============================================================================= + +:- module(c34gl_engine, [ + initial_state/1, + step_form/4, + materialize_table/3, + tape_entries/2, + get_form/3, + get_all_forms/2, + available_events/3, + reset_state/2 +]). + +:- use_module('../../../../__mosaiq_src/33_cpp_clw/_translations/prolog_purs/common/sql_srv_sim/sql_srv_sim'). +:- use_module(form_registry). + + +%% ============================================================================= +%% State Structure +%% ============================================================================= +%% +%% c34gl_state{ +%% db: db{...} — sql_srv_sim DB (the tape) +%% forms: forms{id → form_state} — form head states +%% step_count: int — global step counter +%% spid_map: [TxId-Spid, ...] — who wrote each log entry +%% } +%% +%% form_state{ +%% form_id: atom, — incrementer | doubler +%% spid: atom, — spid_a | spid_b +%% win: atom, — idle | running | closed +%% locals: dict, — form-local variables +%% last_tx: int | none, — TxId of last write +%% history: list — events consumed (newest first) +%% } + + +%% ============================================================================= +%% Initialization +%% ============================================================================= + +initial_state(State) :- + %% Create empty DB and seed the counter table + empty_db(DB0), + exec_list([sql_insert(counter, row{id: 1, value: 0})], DB0, DB1), + + %% Create form heads + FS_Inc = form_state{ + form_id: incrementer, spid: spid_a, win: idle, + locals: locals{count: 0}, last_tx: none, history: [] + }, + FS_Dbl = form_state{ + form_id: doubler, spid: spid_b, win: idle, + locals: locals{value: 0}, last_tx: none, history: [] + }, + + State = c34gl_state{ + db: DB1, + forms: forms{incrementer: FS_Inc, doubler: FS_Dbl}, + step_count: 0, + spid_map: [1-seed] %% TxId 1 = seed insert + }. + + +%% ============================================================================= +%% Step a Form +%% ============================================================================= + +%% step_form(+FormId, +Event, +S0, -S1) +step_form(FormId, Event, S0, S1) :- + %% Look up form state + get_form(FormId, S0, FS0), + %% Delegate to form module + form_step(FormId, Event, FS0, FS1, S0.db, DB1), + %% Record SPID attribution for new log entries + record_attribution(S0.db, DB1, FS0.spid, S0.spid_map, NewMap), + %% Update last_tx + ( DB1.next_tx > S0.db.next_tx + -> LastTx is DB1.next_tx - 1 + ; LastTx = FS0.last_tx + ), + FS2 = FS1.put(_{last_tx: LastTx, history: [Event | FS0.history]}), + %% Assemble new state + NewForms = S0.forms.put(FormId, FS2), + NewStep is S0.step_count + 1, + S1 = S0.put(_{db: DB1, forms: NewForms, step_count: NewStep, spid_map: NewMap}). + + +%% ============================================================================= +%% Query Helpers +%% ============================================================================= + +get_form(FormId, State, FS) :- + get_dict(FormId, State.forms, FS). + +get_all_forms(State, FormsList) :- + dict_pairs(State.forms, _, Pairs), + maplist([_K-V, V]>>true, Pairs, FormsList). + +materialize_table(State, Table, Rows) :- + materialize(State.db, Table, Rows). + +available_events(State, FormId, Events) :- + get_form(FormId, State, FS), + form_available_events(FormId, FS, Events). + +reset_state(_OldState, NewState) :- + initial_state(NewState). + + +%% ============================================================================= +%% Tape Entries (Chronological with SPID Attribution) +%% ============================================================================= + +%% tape_entries(+State, -Entries) +%% Returns the log in chronological order, each entry annotated with SPID. +tape_entries(State, Entries) :- + reverse(State.db.log, ChronLog), + annotate_entries(ChronLog, State.spid_map, Entries). + +annotate_entries([], _, []). +annotate_entries([log_entry(TxId, Op) | Rest], Map, [Entry | RestOut]) :- + ( member(TxId-Spid, Map) + -> true + ; Spid = unknown + ), + op_summary(Op, Table, OpType, Summary), + Entry = tape_entry{ + tx_id: TxId, spid: Spid, op: OpType, + table: Table, summary: Summary + }, + annotate_entries(Rest, Map, RestOut). + + +%% ============================================================================= +%% Operation Summaries (for tape cell display) +%% ============================================================================= + +op_summary(insert(Table, Row), Table, insert, Summary) :- + format(atom(Summary), '~w', [Row]). +op_summary(update(Table, _Pk, NewVals, _OldVals), Table, update, Summary) :- + format(atom(Summary), '~w', [NewVals]). +op_summary(delete(Table, Pk, _OldRow), Table, delete, Summary) :- + format(atom(Summary), 'pk=~w', [Pk]). +op_summary(compensation(InnerOp), Table, compensation, Summary) :- + op_summary(InnerOp, Table, _, InnerSummary), + format(atom(Summary), 'UNDO ~w', [InnerSummary]). +op_summary(begin_tran(Name), '', begin_tran, Name). +op_summary(commit_tran(Name), '', commit, Name). +op_summary(save_tran(Name), '', savepoint, Name). +op_summary(abort_tran, '', abort, abort). +%% Fallback +op_summary(_, '', unknown, '?'). + + +%% ============================================================================= +%% SPID Attribution +%% ============================================================================= + +%% record_attribution(+OldDB, +NewDB, +Spid, +OldMap, -NewMap) +%% Record SPID for any new log entries (TxIds between old.next_tx and new.next_tx - 1). +record_attribution(OldDB, NewDB, Spid, OldMap, NewMap) :- + OldTx = OldDB.next_tx, + NewTx = NewDB.next_tx, + ( NewTx > OldTx + -> numlist(OldTx, NewTx - 1, NewTxIds), + maplist(pair_with(Spid), NewTxIds, NewPairs), + append(OldMap, NewPairs, NewMap) + ; NewMap = OldMap + ). + +pair_with(Spid, TxId, TxId-Spid). + +%% numlist with expression evaluation +numlist(Low, HighExpr, List) :- + High is HighExpr, + ( High >= Low + -> numlist_(Low, High, List) + ; List = [] + ). + +numlist_(Low, High, [Low | Rest]) :- + Low =< High, !, + Next is Low + 1, + numlist_(Next, High, Rest). +numlist_(_, _, []). diff --git a/simulators/c34gl/prolog/form_doubler.pl b/simulators/c34gl/prolog/form_doubler.pl new file mode 100644 index 0000000..41a297f --- /dev/null +++ b/simulators/c34gl/prolog/form_doubler.pl @@ -0,0 +1,50 @@ +%% ============================================================================= +%% form_doubler.pl — Form B: Counter Doubler +%% ============================================================================= +%% +%% A minimal synthetic form that reads a shared counter and doubles it. +%% Events: start → double* → stop +%% ============================================================================= + +:- module(form_doubler, [ + form_step/5, + available_events/2 +]). + +:- use_module('../../../../__mosaiq_src/33_cpp_clw/_translations/prolog_purs/common/sql_srv_sim/sql_srv_sim'). + +%% form_step(+Event, +FS0, -FS1, +DB0, -DB1) + +%% start: read counter from tape, transition to running +form_step(start, FS0, FS1, DB0, DB0) :- + FS0.win == idle, + materialize(DB0, counter, Rows), + ( Rows = [Row|_] -> get_dict(value, Row, Val) ; Val = 0 ), + FS1 = FS0.put(_{win: running, locals: locals{value: Val}}). + +%% double: write counter*2 to tape +form_step(double, FS0, FS1, DB0, DB1) :- + FS0.win == running, + NewVal is FS0.locals.value * 2, + exec_list(FS0.spid, + [sql_update(counter, row{value: NewVal}, where(id, =, 1))], + DB0, DB1), + FS1 = FS0.put(_{locals: locals{value: NewVal}, + last_tx: DB1.next_tx - 1}). + +%% refresh: re-read counter from tape +form_step(refresh, FS0, FS1, DB0, DB0) :- + FS0.win == running, + materialize(DB0, counter, Rows), + ( Rows = [Row|_] -> get_dict(value, Row, Val) ; Val = 0 ), + FS1 = FS0.put(locals, locals{value: Val}). + +%% stop: transition to closed +form_step(stop, FS0, FS1, DB, DB) :- + FS0.win == running, + FS1 = FS0.put(win, closed). + +%% available_events(+FS, -Events) +available_events(FS, [start]) :- FS.win == idle, !. +available_events(FS, [double, refresh, stop]) :- FS.win == running, !. +available_events(_, []). diff --git a/simulators/c34gl/prolog/form_incrementer.pl b/simulators/c34gl/prolog/form_incrementer.pl new file mode 100644 index 0000000..45058d1 --- /dev/null +++ b/simulators/c34gl/prolog/form_incrementer.pl @@ -0,0 +1,50 @@ +%% ============================================================================= +%% form_incrementer.pl — Form A: Counter Incrementer +%% ============================================================================= +%% +%% A minimal synthetic form that reads a shared counter and increments it. +%% Events: start → increment* → stop +%% ============================================================================= + +:- module(form_incrementer, [ + form_step/5, + available_events/2 +]). + +:- use_module('../../../../__mosaiq_src/33_cpp_clw/_translations/prolog_purs/common/sql_srv_sim/sql_srv_sim'). + +%% form_step(+Event, +FS0, -FS1, +DB0, -DB1) + +%% start: read counter from tape, transition to running +form_step(start, FS0, FS1, DB0, DB0) :- + FS0.win == idle, + materialize(DB0, counter, Rows), + ( Rows = [Row|_] -> get_dict(value, Row, Val) ; Val = 0 ), + FS1 = FS0.put(_{win: running, locals: locals{count: Val}}). + +%% increment: write counter+1 to tape +form_step(increment, FS0, FS1, DB0, DB1) :- + FS0.win == running, + NewVal is FS0.locals.count + 1, + exec_list(FS0.spid, + [sql_update(counter, row{value: NewVal}, where(id, =, 1))], + DB0, DB1), + FS1 = FS0.put(_{locals: locals{count: NewVal}, + last_tx: DB1.next_tx - 1}). + +%% refresh: re-read counter from tape (see what others wrote) +form_step(refresh, FS0, FS1, DB0, DB0) :- + FS0.win == running, + materialize(DB0, counter, Rows), + ( Rows = [Row|_] -> get_dict(value, Row, Val) ; Val = 0 ), + FS1 = FS0.put(locals, locals{count: Val}). + +%% stop: transition to closed +form_step(stop, FS0, FS1, DB, DB) :- + FS0.win == running, + FS1 = FS0.put(win, closed). + +%% available_events(+FS, -Events) +available_events(FS, [start]) :- FS.win == idle, !. +available_events(FS, [increment, refresh, stop]) :- FS.win == running, !. +available_events(_, []). diff --git a/simulators/c34gl/prolog/form_registry.pl b/simulators/c34gl/prolog/form_registry.pl new file mode 100644 index 0000000..6c525fd --- /dev/null +++ b/simulators/c34gl/prolog/form_registry.pl @@ -0,0 +1,26 @@ +%% ============================================================================= +%% form_registry.pl — Form Type Registry +%% ============================================================================= + +:- module(form_registry, [ + form_step/6, + form_available_events/3, + registered_forms/1 +]). + +:- use_module(form_incrementer, []). +:- use_module(form_doubler, []). + +%% form_step(+FormType, +Event, +FS0, -FS1, +DB0, -DB1) +form_step(incrementer, Event, FS0, FS1, DB0, DB1) :- + form_incrementer:form_step(Event, FS0, FS1, DB0, DB1). +form_step(doubler, Event, FS0, FS1, DB0, DB1) :- + form_doubler:form_step(Event, FS0, FS1, DB0, DB1). + +%% form_available_events(+FormType, +FS, -Events) +form_available_events(incrementer, FS, Events) :- + form_incrementer:available_events(FS, Events). +form_available_events(doubler, FS, Events) :- + form_doubler:available_events(FS, Events). + +registered_forms([incrementer, doubler]). diff --git a/simulators/c34gl/prolog/session_server.pl b/simulators/c34gl/prolog/session_server.pl new file mode 100644 index 0000000..b94528c --- /dev/null +++ b/simulators/c34gl/prolog/session_server.pl @@ -0,0 +1,268 @@ +%% ============================================================================= +%% session_server.pl — c34gl HTTP REST API + MCP Tool Definitions +%% ============================================================================= +%% +%% Exposes the c34gl engine as a REST API on port 8183. +%% +%% Endpoints: +%% POST /api/c34gl/sessions → create session +%% GET /api/c34gl/sessions/:id → get full state +%% POST /api/c34gl/sessions/:id/step/:formId → step one form +%% POST /api/c34gl/sessions/:id/reset → reset to initial +%% GET /api/c34gl/sessions/:id/tape → tape only +%% GET /api/c34gl/sessions/:id/tables/:table → materialized table +%% DELETE /api/c34gl/sessions/:id → destroy +%% +%% Usage: +%% $ cd ALGT/simulators/c34gl/prolog +%% $ swipl session_server.pl +%% Server starts automatically on port 8183. +%% ============================================================================= + +:- module(session_server, [start_server/1, stop_server/0]). + +:- use_module(library(http/thread_httpd)). +:- use_module(library(http/http_dispatch)). +:- use_module(library(http/http_json)). +:- use_module(library(http/http_cors)). +:- use_module(library(http/http_parameters)). +:- use_module(library(uuid)). +:- use_module(library(lists)). +:- use_module(library(apply)). + +:- use_module(c34gl_engine, [ + initial_state/1, step_form/4, materialize_table/3, + tape_entries/2, get_form/3, available_events/3, reset_state/2 +]). +:- use_module(form_registry, [registered_forms/1]). + + +%% ============================================================================= +%% Session Storage +%% ============================================================================= + +:- dynamic session_state/2. %% session_state(SessionId, C34glState) + +new_session(SessionId, State) :- + uuid(SessionId), + c34gl_engine:initial_state(State), + assert(session_state(SessionId, State)). + +get_session(SessionId, State) :- + session_state(SessionId, State), !. +get_session(_, _) :- + throw(http_reply(not_found(session))). + +update_session(SessionId, NewState) :- + retract(session_state(SessionId, _)), !, + assert(session_state(SessionId, NewState)). + +destroy_session(SessionId) :- + retractall(session_state(SessionId, _)). + + +%% ============================================================================= +%% Server Start/Stop +%% ============================================================================= + +start_server(Port) :- + http_server(http_dispatch, [port(Port)]), + format("~`=t~60|~n", []), + format(" c34gl server on http://localhost:~w~n", [Port]), + format(" UI: http://localhost:~w/static/index.html~n", [Port]), + format(" CORS enabled~n", []), + format("~`=t~60|~n", []). + +stop_server :- http_stop_server(8183, []). + +:- set_setting(http:cors, [*]). + + +%% ============================================================================= +%% Logging +%% ============================================================================= + +log(Fmt, Args) :- + get_time(Now), + stamp_date_time(Now, DateTime, local), + DateTime = date(_,_,_,H,M,S,_,_,_), + Si is truncate(S), + format(user_error, "[~|~`0t~d~2+:~|~`0t~d~2+:~|~`0t~d~2+] ", [H, M, Si]), + format(user_error, Fmt, Args), + nl(user_error), + flush_output(user_error). + + +%% ============================================================================= +%% HTTP Handlers +%% ============================================================================= + +:- http_handler(root(api/c34gl/sessions), handle_sessions, [prefix]). + +%% Static file serving +:- use_module(library(http/http_files)). + +:- http_handler(root(static), serve_static, [prefix]). + +serve_static(Request) :- + source_file(session_server:start_server(_), SrcFile), + file_directory_name(SrcFile, SrcDir), + atom_concat(SrcDir, '/../web/static', StaticDir), + http_reply_from_files(StaticDir, [], Request). + +%% Session request dispatcher +handle_sessions(Request) :- + cors_enable(Request, + [methods([get, post, patch, put, delete, options])]), + memberchk(method(Method), Request), + memberchk(path(Path), Request), + atom_string(Path, PathStr), + split_string(PathStr, "/", "/", Parts), + ( append(["api","c34gl","sessions"], Tail, Parts) + -> catch( + route(Method, Tail, Request), + Error, + handle_error(Error) + ) + ; reply_json_dict(_{error: "Bad path"}, [status(404)]) + ). + +handle_error(http_reply(Status)) :- !, + reply_json_dict(_{error: Status}, [status(404)]). +handle_error(Error) :- + format(atom(Msg), "~w", [Error]), + reply_json_dict(_{error: Msg}, [status(500)]). + + +%% ============================================================================= +%% Routes +%% ============================================================================= + +%% OPTIONS (CORS preflight) +route(options, _, _) :- + format('Content-type: text/plain~n~n'). + +%% POST /sessions — create +route(post, [], _Request) :- + new_session(SessionId, State), + log("NEW ~w", [SessionId]), + state_to_json(SessionId, State, Json), + reply_json_dict(Json). + +%% GET /sessions/:id — full state +route(get, [IdStr], _Request) :- + atom_string(Id, IdStr), + get_session(Id, State), + state_to_json(Id, State, Json), + reply_json_dict(Json). + +%% POST /sessions/:id/step/:formId — step one form +route(post, [IdStr, "step", FormIdStr], Request) :- + atom_string(Id, IdStr), + atom_string(FormId, FormIdStr), + http_read_json_dict(Request, Body), + atom_string(Event, Body.event), + get_session(Id, S0), + ( c34gl_engine:step_form(FormId, Event, S0, S1) + -> update_session(Id, S1), + log("STEP ~w ~w:~w step=~w", [Id, FormId, Event, S1.step_count]), + state_to_json(Id, S1, Json), + reply_json_dict(Json) + ; reply_json_dict(_{error: "Invalid step", formId: FormIdStr, event: Body.event}, + [status(422)]) + ). + +%% POST /sessions/:id/reset — reset +route(post, [IdStr, "reset"], _Request) :- + atom_string(Id, IdStr), + get_session(Id, S0), + c34gl_engine:reset_state(S0, S1), + update_session(Id, S1), + log("RESET ~w", [Id]), + state_to_json(Id, S1, Json), + reply_json_dict(Json). + +%% GET /sessions/:id/tape — tape only +route(get, [IdStr, "tape"], _Request) :- + atom_string(Id, IdStr), + get_session(Id, State), + tape_to_json(State, TapeJson), + reply_json_dict(_{sessionId: Id, tape: TapeJson}). + +%% GET /sessions/:id/tables/:table — materialized table +route(get, [IdStr, "tables", TableStr], _Request) :- + atom_string(Id, IdStr), + atom_string(Table, TableStr), + get_session(Id, State), + c34gl_engine:materialize_table(State, Table, Rows), + reply_json_dict(_{sessionId: Id, table: Table, rows: Rows}). + +%% DELETE /sessions/:id — destroy +route(delete, [IdStr], _Request) :- + atom_string(Id, IdStr), + destroy_session(Id), + log("DELETE ~w", [Id]), + reply_json_dict(_{ok: true}). + + +%% ============================================================================= +%% JSON Serialization +%% ============================================================================= + +state_to_json(SessionId, State, Json) :- + tape_to_json(State, TapeJson), + forms_to_json(State, FormsJson), + tables_to_json(State, TablesJson), + Json = _{ + sessionId: SessionId, + stepCount: State.step_count, + tape: TapeJson, + forms: FormsJson, + tables: TablesJson + }. + +tape_to_json(State, TapeJson) :- + c34gl_engine:tape_entries(State, Entries), + maplist(tape_entry_to_json, Entries, TapeJson). + +tape_entry_to_json(E, Json) :- + Json = _{ + txId: E.tx_id, + spid: E.spid, + op: E.op, + table: E.table, + summary: E.summary + }. + +forms_to_json(State, FormsJson) :- + dict_pairs(State.forms, _, Pairs), + maplist(form_pair_to_json(State), Pairs, FormJsonPairs), + dict_pairs(FormsJson, forms, FormJsonPairs). + +form_pair_to_json(State, FormId-FS, FormId-Json) :- + c34gl_engine:available_events(State, FormId, AvailEvents), + reverse(FS.history, HistChron), + maplist(atom_string, AvailEvents, AvailStrs), + maplist(atom_string, HistChron, HistStrs), + Json = _{ + formId: FormId, + spid: FS.spid, + win: FS.win, + locals: FS.locals, + lastTx: FS.last_tx, + history: HistStrs, + availableEvents: AvailStrs + }. + +tables_to_json(State, TablesJson) :- + ( materialize_table(State, counter, Rows) + -> TablesJson = _{counter: Rows} + ; TablesJson = _{counter: []} + ). + + +%% ============================================================================= +%% Auto-start +%% ============================================================================= + +%% To start: swipl -g "start_server(8183), sleep(3600)" session_server.pl diff --git a/simulators/c34gl/prolog/test_c34gl.pl b/simulators/c34gl/prolog/test_c34gl.pl new file mode 100644 index 0000000..6cc6658 --- /dev/null +++ b/simulators/c34gl/prolog/test_c34gl.pl @@ -0,0 +1,172 @@ +%% ============================================================================= +%% test_c34gl.pl — Headless Tests for c34gl Engine +%% ============================================================================= + +:- use_module(library(plunit)). +:- use_module(c34gl_engine). +:- use_module(form_registry). + +:- begin_tests(c34gl_init). + +test(initial_state_has_two_forms) :- + initial_state(S), + get_form(incrementer, S, Inc), + get_form(doubler, S, Dbl), + Inc.win == idle, + Dbl.win == idle. + +test(initial_counter_is_zero) :- + initial_state(S), + materialize_table(S, counter, [Row]), + get_dict(value, Row, 0). + +test(initial_tape_has_seed) :- + initial_state(S), + tape_entries(S, Entries), + length(Entries, 1), + Entries = [E], + E.op == insert, + E.spid == seed. + +test(initial_available_events) :- + initial_state(S), + available_events(S, incrementer, [start]), + available_events(S, doubler, [start]). + +:- end_tests(c34gl_init). + + +:- begin_tests(c34gl_incrementer). + +test(start_incrementer) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + get_form(incrementer, S1, Inc), + Inc.win == running, + Inc.locals.count == 0. + +test(increment_once) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(incrementer, increment, S1, S2), + materialize_table(S2, counter, [Row]), + get_dict(value, Row, 1), + get_form(incrementer, S2, Inc), + Inc.locals.count == 1. + +test(increment_three_times) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(incrementer, increment, S1, S2), + step_form(incrementer, increment, S2, S3), + step_form(incrementer, increment, S3, S4), + materialize_table(S4, counter, [Row]), + get_dict(value, Row, 3), + S4.step_count == 4. + +test(stop_incrementer) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(incrementer, stop, S1, S2), + get_form(incrementer, S2, Inc), + Inc.win == closed, + available_events(S2, incrementer, []). + +test(increment_tape_entries) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(incrementer, increment, S1, S2), + tape_entries(S2, Entries), + length(Entries, 2), %% seed + 1 update + Entries = [Seed, Upd], + Seed.spid == seed, + Upd.spid == spid_a, + Upd.op == update. + +:- end_tests(c34gl_incrementer). + + +:- begin_tests(c34gl_doubler). + +test(start_doubler) :- + initial_state(S0), + step_form(doubler, start, S0, S1), + get_form(doubler, S1, Dbl), + Dbl.win == running, + Dbl.locals.value == 0. + +test(double_from_zero) :- + initial_state(S0), + step_form(doubler, start, S0, S1), + step_form(doubler, double, S1, S2), + materialize_table(S2, counter, [Row]), + get_dict(value, Row, 0). %% 0 * 2 = 0 + +:- end_tests(c34gl_doubler). + + +:- begin_tests(c34gl_interleaved). + +test(increment_then_double) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(incrementer, increment, S1, S2), %% counter = 1 + step_form(doubler, start, S2, S3), + step_form(doubler, double, S3, S4), %% counter = 1*2 = 2? No: doubler reads tape + %% Doubler reads current tape value (1) on start, then doubles its LOCAL value + %% But doubler.start reads materialize → value=1, so locals.value=1 + %% Then double: 1*2=2, writes 2 to tape + materialize_table(S4, counter, [Row]), + get_dict(value, Row, 2). + +test(double_then_increment) :- + initial_state(S0), + step_form(doubler, start, S0, S1), + step_form(doubler, double, S1, S2), %% 0*2=0 + step_form(incrementer, start, S2, S3), + step_form(incrementer, increment, S3, S4), %% 0+1=1 + materialize_table(S4, counter, [Row]), + get_dict(value, Row, 1). + +test(increment_double_interleaved) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(doubler, start, S1, S2), + %% Both started, both read counter=0 + step_form(incrementer, increment, S2, S3), %% writes 1 + %% Doubler's local value is still 0 (read at start) + step_form(doubler, double, S3, S4), %% writes 0*2=0 (stale read!) + materialize_table(S4, counter, [Row]), + get_dict(value, Row, 0). %% Lost update: increment's 1 was overwritten + +test(refresh_avoids_stale_read) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(doubler, start, S1, S2), + step_form(incrementer, increment, S2, S3), %% counter = 1 + step_form(doubler, refresh, S3, S4), %% doubler re-reads → value=1 + step_form(doubler, double, S4, S5), %% 1*2=2 + materialize_table(S5, counter, [Row]), + get_dict(value, Row, 2). + +test(tape_attribution) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(doubler, start, S1, S2), + step_form(incrementer, increment, S2, S3), + step_form(doubler, double, S3, S4), + tape_entries(S4, Entries), + length(Entries, 3), %% seed + inc_update + dbl_update + Entries = [Seed, IncUpd, DblUpd], + Seed.spid == seed, + IncUpd.spid == spid_a, + DblUpd.spid == spid_b. + +test(step_count_tracks) :- + initial_state(S0), + step_form(incrementer, start, S0, S1), + step_form(doubler, start, S1, S2), + step_form(incrementer, increment, S2, S3), + S3.step_count == 3. + +:- end_tests(c34gl_interleaved). diff --git a/simulators/c34gl/web/static/app.css b/simulators/c34gl/web/static/app.css new file mode 100644 index 0000000..3b2e4eb --- /dev/null +++ b/simulators/c34gl/web/static/app.css @@ -0,0 +1,287 @@ +/* ============================================================================= + c34gl — See-Through 4GL Styles + ============================================================================= */ + +:root { + --bg: #0a0e17; + --surface: #131a2b; + --border: #2a3550; + --text: #c9d1d9; + --text-dim: #6b7688; + --accent: #58a6ff; + --spid-a: #3b82f6; + --spid-a-bg: #1e3a5f; + --spid-b: #10b981; + --spid-b-bg: #1a3d32; + --seed: #6b7688; + --seed-bg: #1c2333; + --danger: #f87171; + --font-mono: 'Cascadia Code', 'JetBrains Mono', 'Fira Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} + +.app { max-width: 1100px; margin: 0 auto; padding: 16px; } + +.loading { + text-align: center; padding: 60px; + color: var(--text-dim); font-size: 18px; +} + +/* Header */ +.header { + display: flex; align-items: center; gap: 16px; + padding: 12px 0; margin-bottom: 16px; + border-bottom: 1px solid var(--border); +} +.header-avatar { + width: 80px; height: 80px; border-radius: 8px; object-fit: cover; +} +.header-title { display: flex; flex-direction: column; } +.header h1 { + font-size: 28px; font-weight: 900; color: var(--accent); margin: 0; line-height: 1.1; + font-family: Impact, 'Arial Black', 'Haettenschweiler', sans-serif; + letter-spacing: 2px; text-transform: uppercase; +} +.header-subtitle { font-size: 12px; color: var(--text-dim); letter-spacing: 0.5px; } +.header .step-count { color: var(--text-dim); font-family: var(--font-mono); font-size: 13px; } + +/* Controls */ +.controls { display: flex; gap: 8px; margin-left: auto; } +.controls button { + padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; + background: var(--surface); color: var(--text); font-size: 13px; + cursor: pointer; font-family: var(--font-sans); transition: all 0.15s; +} +.controls button:hover { border-color: var(--accent); color: var(--accent); } + +/* Form Panels — Clarion/Windows 3.1 style */ +.forms-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; } + +.form-panel { + background: #c0c0c0; + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-bottom: 2px solid #404040; + border-right: 2px solid #404040; + padding: 0; position: relative; + font-family: 'MS Sans Serif', 'Segoe UI', Tahoma, Geneva, sans-serif; + box-shadow: 1px 1px 0 #000; +} + +.form-panel .form-title { + font-weight: 700; font-size: 12px; margin: 0; + display: flex; align-items: center; gap: 6px; + padding: 3px 4px; + color: #fff; + letter-spacing: 0; +} +.form-panel.incrementer .form-title { background: linear-gradient(90deg, #000080, #1084d0); } +.form-panel.doubler .form-title { background: linear-gradient(90deg, #006000, #20a020); } + +.form-panel .spid-badge { + font-size: 9px; font-family: var(--font-mono); + padding: 1px 4px; + background: rgba(255,255,255,0.2); color: #ddd; + margin-left: auto; +} + +.form-panel .form-body { + padding: 8px 10px; +} + +.form-panel .win-state { + font-size: 11px; color: #000; margin-bottom: 4px; +} + +.form-panel .locals { + font-family: var(--font-mono); font-size: 18px; + font-weight: 700; margin: 6px 0; + color: #000; + background: #fff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-bottom: 1px solid #ffffff; + border-right: 1px solid #ffffff; + padding: 3px 6px; +} + +.form-panel .events { + display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; +} +.form-panel .events button { + padding: 3px 12px; font-size: 11px; + font-family: 'MS Sans Serif', 'Segoe UI', Tahoma, sans-serif; + cursor: pointer; + background: #c0c0c0; + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-bottom: 2px solid #404040; + border-right: 2px solid #404040; + color: #000; + min-width: 60px; + text-align: center; +} +.form-panel .events button:active { + border-top: 2px solid #404040; + border-left: 2px solid #404040; + border-bottom: 2px solid #ffffff; + border-right: 2px solid #ffffff; +} +.form-panel .events button:hover { + background: #d0d0d0; +} +.form-panel .events button:disabled { + color: #808080; cursor: not-allowed; +} + +.form-panel .history { + margin-top: 6px; font-size: 10px; color: #606060; + font-family: var(--font-mono); + border-top: 1px solid #808080; + padding-top: 4px; +} + +/* DCG diagram inside Clarion form */ +.form-panel .dcg-diagram { + margin: 4px 0; +} +.form-panel .dcg-node { + background: #c0c0c0; border: 1px solid #808080; + color: #000; font-size: 10px; padding: 1px 5px; +} +.form-panel .dcg-node.active { + background: #000080; color: #fff; border-color: #000080; + font-weight: 700; +} +.form-panel.doubler .dcg-node.active { + background: #006000; border-color: #006000; +} +.form-panel .dcg-arrow { color: #808080; font-size: 9px; } + +/* Tape */ +.tape-section { margin-bottom: 16px; } +.tape-label { + font-size: 12px; font-weight: 600; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; +} + +.tape { + display: flex; gap: 3px; overflow-x: auto; + padding: 12px 8px; background: var(--surface); + border: 1px solid var(--border); border-radius: 8px; + min-height: 80px; align-items: flex-start; +} + +.tape-cell { + min-width: 90px; max-width: 110px; padding: 8px; + border: 2px solid var(--border); border-radius: 6px; + font-family: var(--font-mono); font-size: 11px; + flex-shrink: 0; position: relative; cursor: pointer; + transition: all 0.15s; +} +.tape-cell:hover { transform: translateY(-2px); } + +.tape-cell .tx-id { + font-weight: 700; font-size: 10px; color: var(--text-dim); + margin-bottom: 4px; +} +.tape-cell .op-type { font-weight: 600; margin-bottom: 2px; color: #ff7b72; } +.tape-cell .tape-tbl { font-size: 10px; color: #c084fc; margin-bottom: 2px; } +.tape-cell .summary { font-size: 10px; color: var(--text-dim); word-break: break-all; } + +.tape-cell.spid_a { border-color: var(--spid-a); background: var(--spid-a-bg); } +.tape-cell.spid_b { border-color: var(--spid-b); background: var(--spid-b-bg); } +.tape-cell.seed { border-color: var(--seed); background: var(--seed-bg); } + +.tape-cell.compensation { + text-decoration: line-through; opacity: 0.5; + border-style: dashed; +} + +/* Head indicators */ +.tape-heads { + display: flex; gap: 3px; padding: 0 8px; + margin-top: 4px; +} +.tape-head-slot { + min-width: 90px; max-width: 110px; flex-shrink: 0; + text-align: center; font-size: 11px; font-family: var(--font-mono); + height: 20px; +} +.tape-head-slot.head-a { color: var(--spid-a); } +.tape-head-slot.head-b { color: var(--spid-b); } + +/* Table */ +.table-section { margin-bottom: 16px; } + +.materialized-table { + width: 100%; border-collapse: collapse; + background: var(--surface); border-radius: 8px; + overflow: hidden; +} +.materialized-table th { + text-align: left; padding: 8px 12px; + background: var(--bg); color: var(--text-dim); + font-size: 11px; text-transform: uppercase; letter-spacing: 1px; + border-bottom: 1px solid var(--border); +} +.materialized-table td { + padding: 8px 12px; font-family: var(--font-mono); + border-bottom: 1px solid var(--border); font-size: 14px; +} + +/* LLM Panel */ +.llm-panel { + background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; padding: 14px; margin-top: 12px; + display: flex; gap: 12px; align-items: flex-start; +} +.llm-avatar { + width: 48px; height: 48px; border-radius: 6px; + flex-shrink: 0; object-fit: cover; +} +.llm-text { + font-size: 13px; color: var(--text-dim); line-height: 1.6; + font-style: italic; flex: 1; +} + +/* Tape Minimap — 1-2px per character, syntax colored */ +.tape-minimap { + background: #1a1e2e; border: 1px solid var(--border); + border-radius: 6px; padding: 4px; margin-bottom: 8px; + overflow: hidden; position: relative; +} +.tape-minimap canvas { + display: block; width: 100%; image-rendering: pixelated; +} + +/* DCG State Diagram */ +.dcg-diagram { + display: flex; align-items: center; gap: 4px; + margin: 6px 0; font-family: var(--font-mono); font-size: 12px; +} +.dcg-node { + padding: 3px 8px; border: 1px solid var(--border); + border-radius: 4px; color: var(--text-dim); background: var(--bg); +} +.dcg-node.active { + border-color: var(--accent); color: var(--accent); + background: rgba(88, 166, 255, 0.1); font-weight: 700; +} +.dcg-arrow { color: var(--text-dim); font-size: 10px; } + +/* Empty state */ +.tape-empty { + color: var(--text-dim); font-style: italic; + padding: 20px; text-align: center; width: 100%; +} diff --git a/simulators/c34gl/web/static/app.js b/simulators/c34gl/web/static/app.js new file mode 100644 index 0000000..a581b69 --- /dev/null +++ b/simulators/c34gl/web/static/app.js @@ -0,0 +1,383 @@ +// ============================================================================= +// c34gl — See-Through 4GL Frontend +// ============================================================================= + +const API = '/api/c34gl'; +const SEAGULL_IMG = '/static/seagull.png'; + +let state = { sessionId: null, data: null }; + +// ============================================================================= +// API Layer +// ============================================================================= + +async function api(path, opts = {}) { + const url = API + path; + const headers = { 'Content-Type': 'application/json' }; + const res = await fetch(url, { ...opts, headers }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + return res.json(); +} + +async function createSession() { + const data = await api('/sessions', { method: 'POST', body: '{}' }); + state.sessionId = data.sessionId; + state.data = data; + render(); +} + +async function stepForm(formId, event) { + try { + const data = await api( + `/sessions/${state.sessionId}/step/${formId}`, + { method: 'POST', body: JSON.stringify({ event }) } + ); + state.data = data; + render(); + // Auto-scroll tape to end + const tape = document.querySelector('.tape'); + if (tape) tape.scrollLeft = tape.scrollWidth; + } catch (e) { + console.error('Step failed:', e); + } +} + +async function resetSession() { + const data = await api(`/sessions/${state.sessionId}/reset`, { method: 'POST' }); + state.data = data; + render(); +} + +// ============================================================================= +// Render +// ============================================================================= + +function render() { + const app = document.getElementById('app'); + if (!state.data) { + app.innerHTML = '
Initializing c34gl...
'; + return; + } + const d = state.data; + app.innerHTML = ` + ${renderHeader(d)} +
+ ${renderFormPanel('incrementer', d)} + ${renderFormPanel('doubler', d)} +
+ ${renderTape(d)} + ${renderTable(d)} + ${renderLLMPanel(d)} + `; + attachHandlers(); +} + +// ============================================================================= +// Header + Controls +// ============================================================================= + +function renderHeader(d) { + return ` +
+ c34gl +

C34GL

See-Through 4GL
+ step ${d.stepCount} +
+ +
+
`; +} + +// ============================================================================= +// Form Panels with DCG State Diagram +// ============================================================================= + +function renderFormPanel(formId, d) { + const f = d.forms[formId]; + if (!f) return ''; + const localsStr = Object.entries(f.locals) + .map(([k,v]) => `${v}`) + .join(', '); + const localLabel = formId === 'incrementer' ? 'count' : 'value'; + + return ` +
+
+ ${formId === 'incrementer' ? 'Incrementer' : 'Doubler'} + ${f.spid} +
+
+ ${renderDCGDiagram(formId, f.win)} +
${localLabel} = ${localsStr}
+
+ ${f.availableEvents.map(e => + `` + ).join('')} +
+
+ ${f.history.length > 0 + ? f.history.slice(-6).join(' → ') + : 'no events yet'} +
+
+
`; +} + +function renderDCGDiagram(formId, currentWin) { + // Minimal DCG state diagram: idle → running → closed + const states = ['idle', 'running', 'closed']; + const events = formId === 'incrementer' + ? { 'idle': 'start', 'running': 'inc/stop' } + : { 'idle': 'start', 'running': 'dbl/stop' }; + + const nodes = states.map(s => { + const active = s === currentWin; + const cls = active ? 'dcg-node active' : 'dcg-node'; + return `${s}`; + }).join(''); + + return `
${nodes}
`; +} + +// ============================================================================= +// Tape (Transaction Log Minimap with Syntax Highlighting) +// ============================================================================= + +function renderTape(d) { + const tape = d.tape || []; + + // Build fn_dblog-style text lines for minimap canvas rendering + const minimapLines = tape.map(e => { + const seq = String(e.txId).padStart(3, ' '); + const op = fnDblogOp(e.op); + const tran = (e.spid || 'NULL').padEnd(8); + const tbl = (e.table || '').padEnd(12); + const note = e.op === 'compensation' ? '(UNDO)' : ''; + return { + text: `${seq}|${op}|${tran}|${tbl}|${note}`, + spid: e.spid || 'seed', + // Token boundaries for syntax highlighting + regions: [ + { start: 0, end: 3, type: 'seq' }, + { start: 4, end: 4 + op.length, type: 'op' }, + { start: 4 + op.length + 1, end: 4 + op.length + 1 + tran.length, type: 'tran' }, + { start: 4 + op.length + 1 + tran.length + 1, end: 4 + op.length + 1 + tran.length + 1 + tbl.length, type: 'tbl' }, + { start: 4 + op.length + 1 + tran.length + 1 + tbl.length + 1, end: 999, type: 'note' } + ] + }; + }); + + // Head position indicators on minimap (shown as labels) + const headLabels = []; + if (d.forms.incrementer && d.forms.incrementer.lastTx !== 'none') + headLabels.push(`▲ A @ #${d.forms.incrementer.lastTx}`); + if (d.forms.doubler && d.forms.doubler.lastTx !== 'none') + headLabels.push(`▲ B @ #${d.forms.doubler.lastTx}`); + const headsHtml = headLabels.length > 0 + ? `
${headLabels.join(' ')}
` : ''; + + // Store minimap data for canvas drawing after render + window._minimapData = minimapLines; + + return ` +
+
fn_dblog ${headsHtml}
+
+
`; +} + +// ============================================================================= +// Materialized Table +// ============================================================================= + +function renderTable(d) { + const rows = (d.tables && d.tables.counter) || []; + if (rows.length === 0) return ''; + const cols = Object.keys(rows[0]); + return ` +
+
Materialized: counter
+ + ${cols.map(c => ``).join('')} + + ${rows.map(r => + `${cols.map(c => ``).join('')}` + ).join('')} + +
${c}
${r[c]}
+
`; +} + +// ============================================================================= +// LLM Observer Panel +// ============================================================================= + +function renderLLMPanel(d) { + const commentary = generateCommentary(d); + return ` +
+ c34gl +
${commentary}
+
`; +} + +function generateCommentary(d) { + const tape = d.tape || []; + const counter = (d.tables && d.tables.counter && d.tables.counter[0]) || {}; + const inc = d.forms.incrementer; + const dbl = d.forms.doubler; + + if (d.stepCount === 0) { + return 'Counter initialized to 0. Both forms are idle. Start one to begin.'; + } + + const parts = []; + parts.push(`Counter is ${counter.value} after ${d.stepCount} step${d.stepCount !== 1 ? 's' : ''}.`); + + // Detect stale read potential + if (inc && inc.win === 'running' && dbl && dbl.win === 'running') { + if (inc.locals.count !== counter.value) { + parts.push(`Incrementer's local count (${inc.locals.count}) is stale — tape shows ${counter.value}.`); + } + if (dbl.locals.value !== counter.value) { + parts.push(`Doubler's local value (${dbl.locals.value}) is stale — tape shows ${counter.value}.`); + } + } + + // Last event + if (tape.length > 1) { + const last = tape[tape.length - 1]; + if (last.op === 'update') { + const who = last.spid === 'spid_a' ? 'Incrementer' : 'Doubler'; + parts.push(`Last write by ${who}: ${last.summary}`); + } + } + + return parts.join(' '); +} + +// ============================================================================= +// Event Handlers +// ============================================================================= + +function attachHandlers() { + document.querySelectorAll('.events button').forEach(btn => { + btn.addEventListener('click', () => { + const formId = btn.dataset.form; + const event = btn.dataset.event; + stepForm(formId, event); + }); + }); + drawMinimap(); +} + +// ============================================================================= +// Minimap Canvas — 2px per character, syntax colored +// ============================================================================= + +const MINIMAP_COLORS = { + // Per-token colors (fn_dblog syntax highlighting) + seq: '#6b7688', // dim gray for sequence number + op: '#ff7b72', // red-orange for LOP_ operation keywords + tran: '#79c0ff', // light blue for transaction/spid name + tbl: '#c084fc', // purple for table name + note: '#ffa657', // orange for (UNDO) annotations + pipe: '#3a4250', // dim for pipe delimiters + // Per-SPID gutter colors + spid_a: '#3b82f6', + spid_b: '#10b981', + seed: '#6b7688', + unknown: '#4a5568' +}; + +function drawMinimap() { + const canvas = document.getElementById('minimap-canvas'); + const lines = window._minimapData; + if (!canvas || !lines || lines.length === 0) return; + + const PX = 2; // pixels per character + const LINE_H = 3; // pixels per line + const GUTTER = 4; // gutter width for SPID color bar + const maxCols = 80; + const w = GUTTER + maxCols * PX; + const h = Math.max(lines.length * LINE_H, 6); + + canvas.width = w; + canvas.height = h; + canvas.style.height = h + 'px'; + + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1a1e2e'; + ctx.fillRect(0, 0, w, h); + + lines.forEach((line, row) => { + const y = row * LINE_H; + const text = line.text; + const spid = line.spid; + const regions = line.regions; + + // Gutter: SPID color bar + ctx.fillStyle = MINIMAP_COLORS[spid] || MINIMAP_COLORS.unknown; + ctx.fillRect(0, y, GUTTER - 1, LINE_H - 1); + + // Characters: color by fn_dblog token region + for (let i = 0; i < text.length && i < maxCols; i++) { + const ch = text[i]; + if (ch === '|') { + ctx.fillStyle = MINIMAP_COLORS.pipe; + ctx.fillRect(GUTTER + i * PX, y, PX, LINE_H - 1); + continue; + } + if (ch === ' ') continue; + + // Find which region this char belongs to + let color = MINIMAP_COLORS.seq; + for (const r of regions) { + if (i >= r.start && i < r.end) { + color = MINIMAP_COLORS[r.type] || color; + break; + } + } + + ctx.fillStyle = color; + ctx.fillRect(GUTTER + i * PX, y, PX, LINE_H - 1); + } + }); +} + +// ============================================================================= +// Utilities +// ============================================================================= + +function fnDblogOp(op) { + const map = { + insert: 'LOP_INSERT_ROWS', + update: 'LOP_MODIFY_ROW', + delete: 'LOP_DELETE_ROWS', + compensation: 'LOP_MODIFY_ROW', + begin_tran: 'LOP_BEGIN_XACT', + commit: 'LOP_COMMIT_XACT', + abort: 'LOP_ABORT_XACT', + savepoint: 'LOP_SAVE_XACT', + unknown: 'LOP_UNKNOWN' + }; + return (map[op] || 'LOP_' + op.toUpperCase()).padEnd(18); +} + +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} + +function truncate(s, n) { + s = String(s); + return s.length > n ? s.slice(0, n) + '…' : s; +} + +// ============================================================================= +// Init +// ============================================================================= + +createSession(); diff --git a/simulators/c34gl/web/static/index.html b/simulators/c34gl/web/static/index.html new file mode 100644 index 0000000..caaadce --- /dev/null +++ b/simulators/c34gl/web/static/index.html @@ -0,0 +1,15 @@ + + + + + + c34gl — See-Through 4GL + + + +
+
Initializing c34gl...
+
+ + +