Selaa lähdekoodia

feat: raw terminal log — session_raw_log migration, GET /log polling while a session is active, SSE raw-chunk streaming rendered as one unstyled pre (entry-type view kept for legacy sessions); fix response body slicing by written size (steal_data buffer is not NUL-terminated)

clanker 1 kuukausi sitten
vanhempi
sitoutus
279e7201a2

+ 1 - 0
meson.build

@@ -110,6 +110,7 @@ app_sources = files(
     'src/Data/Migrations/M0002_Part2.vala',
     'src/Data/Migrations/M0003_Rules.vala',
     'src/Data/Migrations/M0004_ProjectNotes.vala',
+    'src/Data/Migrations/M0005_RawLog.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',

+ 64 - 0
src/Api/TerminalSse.vala

@@ -19,6 +19,11 @@ namespace ClankerSaddle.Api {
      * the terminal page renders with per-type styling (command, comment,
      * block, user-comment, progress, review, plan, shell-reset, failed,
      * query).
+     *
+     * Sessions with raw PTY rows (`session_raw_log`, the PTY-terminal
+     * feature) stream those instead: `{"seq", "type": "raw", "text"}`
+     * events in seq order, polled the same way. The client renders them
+     * as one unstyled `<pre>` of concatenated chunks.
      */
     public class TerminalSseEndpoint : SseEndpoint {
 
@@ -33,6 +38,21 @@ namespace ClankerSaddle.Api {
             route_context.mapped_parameters.try_get("session_id", out id_text);
             int64 session_id = int64.parse(id_text);
 
+            bool has_raw = false;
+            try {
+                var raw_probe = yield db.query<SessionRawLog>()
+                    .where(expr("session_id == $0", elem<int64?>(session_id)))
+                    .first_async();
+                has_raw = raw_probe != null;
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] Terminal stream probe for session $(session_id) failed: $(e.message)");
+                return;
+            }
+            if (has_raw) {
+                yield stream_raw_log(session_id, stream);
+                return;
+            }
+
             int64 cursor = 0;
             try {
                 while (!stream.is_closed) {
@@ -62,6 +82,34 @@ namespace ClankerSaddle.Api {
             }
         }
 
+        /** The raw-log variant of the stream loop above. */
+        private async void stream_raw_log(int64 session_id, SseStream stream) {
+            int64 cursor = 0;
+            try {
+                while (!stream.is_closed) {
+                    bool pushed = false;
+                    foreach (var chunk in yield db.query<SessionRawLog>()
+                            .where(expr("session_id == $0 && seq > $1",
+                                elem<int64?>(session_id), elem<int64?>(cursor)))
+                            .order_by(expr("seq"))
+                            .materialise_async()) {
+                        yield stream.send_event(new SseEvent.with_id(
+                            chunk.seq.to_string(), raw_payload(chunk)));
+                        cursor = chunk.seq;
+                        pushed = true;
+                    }
+                    if (!pushed) {
+                        yield stream.send_event(new SseEvent("ping"));
+                    }
+                    yield nap_seconds(1);
+                }
+            } catch (Error e) {
+                if (!stream.is_closed) {
+                    warning(@"[ClankerSaddle] Raw terminal stream for session $(session_id) ended: $(e.message)");
+                }
+            }
+        }
+
         /** The entry as a one-line JSON object payload. */
         private static string payload(SessionCommand entry) {
             var builder = new Json.Builder();
@@ -84,6 +132,22 @@ namespace ClankerSaddle.Api {
             return generator.to_data(null);
         }
 
+        /** A raw chunk as a one-line JSON object payload. */
+        private static string raw_payload(SessionRawLog chunk) {
+            var builder = new Json.Builder();
+            builder.begin_object();
+            builder.set_member_name("seq");
+            builder.add_int_value(chunk.seq);
+            builder.set_member_name("type");
+            builder.add_string_value("raw");
+            builder.set_member_name("text");
+            builder.add_string_value(chunk.chunk);
+            builder.end_object();
+            var generator = new Json.Generator();
+            generator.set_root(builder.get_root());
+            return generator.to_data(null);
+        }
+
         private static async void nap_seconds(uint seconds) {
             GLib.Timeout.add_seconds(seconds, () => {
                 nap_seconds.callback();

+ 22 - 0
src/Data/Entities.vala

@@ -339,6 +339,28 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One raw PTY output chunk of a {@link Session}, pulled from the
+     * daemon's `GET /log` ring buffer while the session is active
+     * (`session_raw_log`); `seq` is the daemon's monotonic chunk
+     * sequence number.
+     */
+    public class SessionRawLog : Object {
+
+        public int64 id { get; set; }
+        public int64 session_id { get; set; }
+        public int64 seq { get; set; }
+        public string chunk { get; set; default = ""; }
+
+        public static void entity_mapping(EntityMapperBuilder<SessionRawLog> cfg) {
+            cfg.table("session_raw_log")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("session_id", o => o.session_id, (o, v) => o.session_id = v)
+                .column<int64?>("seq", o => o.seq, (o, v) => o.seq = v)
+                .column<string>("chunk", o => o.chunk, (o, v) => o.chunk = v);
+        }
+    }
+
     /**
      * A bearer token authorising one saddle daemon's calls against the
      * `/api/saddle/*` endpoints, generated at registration.

+ 40 - 0
src/Data/Migrations/M0005_RawLog.vala

@@ -0,0 +1,40 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Web-stack raw terminal log: the daemon's PTY output chunks (GET
+     * /log?since=), appended while a session is active. When a session
+     * has rows here, the terminal view renders the concatenated raw log
+     * instead of the typed entry stream (which older sessions keep
+     * using). The (session_id, seq) pair is unique so poll retries can
+     * never duplicate a chunk.
+     */
+    public class M0005_RawLog : Migration {
+
+        public override string name { get { return "Raw log: session_raw_log"; } }
+        public override uint64 serial { get { return 5; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.create_table("session_raw_log", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("session_id")
+                    .not_null()
+                    .references("sessions", "id")
+                    .indexed();
+                t.column<int64?>("seq")
+                    .not_null();
+                t.column<string>("chunk")
+                    .not_null();
+                t.unique("uq_session_raw_log_seq", {"session_id", "seq"});
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("session_raw_log");
+        }
+    }
+}

+ 46 - 3
src/Services/SaddlePollState.vala

@@ -6,9 +6,10 @@ namespace ClankerSaddle.Services {
     /**
      * Process-wide saddle poll bookkeeping: the last daemon-side output
      * entry id stored per session (so repeated polls never duplicate
-     * `session_commands` rows), the block answers and human comments
-     * already handed to a saddle (each delivered exactly once; a restart
-     * re-delivers and the daemon deduplicates).
+     * `session_commands` rows), the last daemon-side raw-log `seq`
+     * stored per session (`session_raw_log`), the block answers and
+     * human comments already handed to a saddle (each delivered exactly
+     * once; a restart re-delivers and the daemon deduplicates).
      *
      * A singleton by necessity: {@link SaddleService} is request-scoped,
      * so this object is where the cross-request cursors live. Ids are
@@ -17,6 +18,8 @@ namespace ClankerSaddle.Services {
     public class SaddlePollState : Object {
 
         private Dictionary<string, string> last_stored_output_id = new Dictionary<string, string>();
+        private Dictionary<string, string> last_stored_raw_seq = new Dictionary<string, string>();
+        private HashSet<string> raw_polling_started = new HashSet<string>();
         private HashSet<string> delivered_answers = new HashSet<string>();
         private HashSet<string> delivered_comments = new HashSet<string>();
         private Mutex state_lock = Mutex();
@@ -37,6 +40,46 @@ namespace ClankerSaddle.Services {
             state_lock.unlock();
         }
 
+        /**
+         * Starts raw-log polling for `session_id` from the baseline seq
+         * (the daemon's current `next`, read before the assignment is
+         * pushed). Later calls for an already-started session are
+         * ignored — a resumed session continues from its stored cursor.
+         */
+        public void start_raw_polling(int64 session_id, int64 baseline_seq) {
+            state_lock.lock();
+            string key = session_id.to_string();
+            if (raw_polling_started.add(key)) {
+                last_stored_raw_seq.set(key, baseline_seq.to_string());
+            }
+            state_lock.unlock();
+        }
+
+        /**
+         * The last raw-log seq stored for `session_id`, or null when
+         * raw polling never started (a daemon without GET /log).
+         */
+        public int64? raw_since_for(int64 session_id) {
+            state_lock.lock();
+            string stored = "";
+            bool found = last_stored_raw_seq.try_get(session_id.to_string(), out stored)
+                && raw_polling_started.has(session_id.to_string());
+            state_lock.unlock();
+            // A ternary with a boxed null crashes valac's codegen here;
+            // branch explicitly instead.
+            if (found) {
+                return int64.parse(stored);
+            }
+            return null;
+        }
+
+        /** Records the new high-water raw-log seq for `session_id`. */
+        public void store_raw_since(int64 session_id, int64 seq) {
+            state_lock.lock();
+            last_stored_raw_seq.set(session_id.to_string(), seq.to_string());
+            state_lock.unlock();
+        }
+
         /**
          * Marks `block_id`'s answer as delivered; returns false when it
          * had already been handed over.

+ 99 - 3
src/Services/SaddleService.vala

@@ -534,6 +534,12 @@ namespace ClankerSaddle.Services {
          * `POST /start` push, when every logged entry belongs to earlier
          * sessions — keeps prior sessions' entries out of the new
          * session's audit trail and its domain events.
+         *
+         * The same probe starts raw-log polling (feature: PTY terminal)
+         * when the daemon serves `GET /log`: the response's `next` seq
+         * becomes the session's baseline, so only chunks produced after
+         * the push land in `session_raw_log`. A daemon without the
+         * endpoint (404/405) keeps the typed-entry terminal view.
          */
         public async void seed_session_cursor(int64 saddle_id, int64 session_id) throws Error {
             var saddle = yield get_saddle(saddle_id);
@@ -545,6 +551,28 @@ namespace ClankerSaddle.Services {
                 && status.get_member("latest_entry_id").get_value_type() == typeof(int64)
                 ? status.get_int_member("latest_entry_id") : 0;
             poll_state.store_since(session_id, baseline);
+
+            if (poll_state.raw_since_for(session_id) != null) {
+                return;
+            }
+            try {
+                Json.Node? root = null;
+                int code = yield request_saddle(((!)saddle).host, "GET", "/log?since=0",
+                    null, out root);
+                if (code >= 400 || root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                    return;
+                }
+                var log = root.get_object();
+                int64 next = log.has_member("next")
+                    && log.get_member("next").get_value_type() == typeof(int64)
+                    ? log.get_int_member("next") : 0;
+                if (next > 0) {
+                    poll_state.start_raw_polling(session_id, next);
+                }
+            } catch (Error e) {
+                // Raw logging is optional; the typed-entry view still works.
+                debug(@"[ClankerSaddle] Raw log probe for session $(session_id) failed: $(e.message)");
+            }
         }
 
         /**
@@ -569,6 +597,7 @@ namespace ClankerSaddle.Services {
             var session = yield tasks.open_session_for_task(task_id);
             if (session != null) {
                 var fresh = yield store_new_output(saddle_id, ((!)session).id);
+                yield store_new_raw_log(((!)saddle).id, ((!)session).id);
                 if (yield apply_entry_events(task_id, fresh, tasks)) {
                     return true;
                 }
@@ -886,6 +915,70 @@ namespace ClankerSaddle.Services {
             return stored;
         }
 
+        /**
+         * Appends the daemon's new raw PTY chunks (`GET /log?since=`,
+         * wire contract `{chunks:[{seq,text}],next:N}` — `since` is the
+         * inclusive resume point, `next` the `since` for the following
+         * call) to the open session's `session_raw_log` rows, advancing
+         * the per-session seq cursor per stored chunk (a failure
+         * mid-pass never re-stores what already landed; the unique
+         * (session_id, seq) pair is the last line of defence). A no-op
+         * for sessions whose daemon had no `/log` at seed time, or once
+         * the daemon stopped serving it.
+         */
+        private async void store_new_raw_log(int64 saddle_id, int64 session_id) throws Error {
+            int64 since = poll_state.raw_since_for(session_id) ?? -1;
+            if (since < 0) {
+                return;
+            }
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null) {
+                return;
+            }
+
+            Json.Node? root = null;
+            int status_code = yield request_saddle(((!)saddle).host, "GET",
+                @"/log?since=$(since)", null, out root);
+            if (status_code == 404 || status_code == 405) {
+                return;
+            }
+            if (status_code >= 400 || root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 26,
+                    @"Saddle log request returned HTTP $(status_code)");
+            }
+
+            var body = root.get_object();
+            if (body.has_member("chunks")
+                    && body.get_member("chunks").get_node_type() == Json.NodeType.ARRAY) {
+                foreach (var element in body.get_array_member("chunks").get_elements()) {
+                    if (element.get_node_type() != Json.NodeType.OBJECT) {
+                        continue;
+                    }
+                    var chunk = element.get_object();
+                    int64 seq = chunk.has_member("seq")
+                        && chunk.get_member("seq").get_value_type() == typeof(int64)
+                        ? chunk.get_int_member("seq") : 0;
+                    string text = string_member(chunk, "text");
+                    if (seq < since || text.length == 0) {
+                        continue;
+                    }
+                    yield db.insert_async<SessionRawLog>(new SessionRawLog() {
+                        session_id = session_id,
+                        seq = seq,
+                        chunk = text
+                    });
+                    since = seq + 1;
+                    poll_state.store_raw_since(session_id, since);
+                }
+            }
+            int64 next = body.has_member("next")
+                && body.get_member("next").get_value_type() == typeof(int64)
+                ? body.get_int_member("next") : since;
+            if (next > since) {
+                poll_state.store_raw_since(session_id, next);
+            }
+        }
+
         /**
          * Projects the agent's conversation-level events onto the task:
          * `saddle comment` lines become bot comments, `saddle subtask`
@@ -1127,10 +1220,13 @@ namespace ClankerSaddle.Services {
                     @"Failed reading saddle response: $(e.message)");
             }
 
+            var written = output.get_data_size();
             uint8[] data = output.steal_data();
-            // steal_data's array length is -1; the buffer itself is
-            // NUL-terminated, so read it as a string directly.
-            string response_text = data != null && data[0] != '\0' ? (string) data : "{}";
+            // steal_data's array length is -1 and the buffer is sized to
+            // the data — there is no NUL terminator to rely on — so slice
+            // by the written byte count before reading it as a string.
+            string response_text = written > 0 && data != null && data[0] != '\0'
+                ? ((string) data).substring(0, (long) written) : "{}";
 
             var parser = new Json.Parser();
             try {

+ 12 - 0
src/Services/TaskService.vala

@@ -622,6 +622,18 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
+        /**
+         * True when the session carries raw PTY log rows — the terminal
+         * view then renders the concatenated raw log instead of the
+         * typed entry stream.
+         */
+        public async bool has_raw_log(int64 session_id) throws Error {
+            var probe = yield db.query<SessionRawLog>()
+                .where(expr("session_id == $0", elem<int64?>(session_id)))
+                .first_async();
+            return probe != null;
+        }
+
         /**
          * The session still open for `task_id` (no `ended_at`), or null.
          * The null check runs in Vala: the expression layer cannot compare

+ 7 - 3
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -22,6 +22,7 @@ namespace ClankerSaddle {
         public string task_title { get; set; default = ""; }
         public int64 session_id { get; set; }
         public string session_state { get; set; default = ""; }
+        public bool has_raw_log { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
     }
 
@@ -30,9 +31,11 @@ namespace ClankerSaddle {
      * log. The `session_id` query parameter selects which of the task's
      * sessions to show (the latest when absent). The log itself renders
      * client-side from the `/terminal-stream/{session_id}` SSE stream —
+     * one unstyled `<pre>` of concatenated raw chunks when the session
+     * carries raw PTY rows ({@link TerminalPublic.has_raw_log}), the
      * typed entry cards with expandable output and text-highlight
-     * commenting — so this state carries only the session table and the
-     * sealed comment action.
+     * commenting otherwise — so this state carries only the session
+     * table and the sealed comment action.
      */
     public class TaskTerminalEntrypoint : StatumEntrypoint {
 
@@ -82,7 +85,8 @@ namespace ClankerSaddle {
                 exists = true,
                 task_id = task_id,
                 task_title = ((!)detail).task.title,
-                session_id = selected
+                session_id = selected,
+                has_raw_log = yield task_service.has_raw_log(selected)
             };
             var sealed = new TaskActionPrivate() { task_id = task_id };
             pub_with_session.comment = action_registry.author_private<AddCommentAction, TaskActionPrivate>(sealed);

+ 2 - 0
src/main.vala

@@ -30,6 +30,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectNotes>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0005_RawLog>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -45,6 +46,7 @@ int main(string[] args) {
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
+        db.add_entity<SessionRawLog>(SessionRawLog.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();

+ 7 - 0
src/pages/app.css

@@ -507,6 +507,13 @@ summary { cursor: pointer; font-weight: 600; }
     word-break: break-all;
 }
 
+/* Raw PTY log — the daemon's chunks, byte-exact and unstyled */
+.terminal-raw {
+    margin: 0;
+    white-space: pre;
+    word-break: normal;
+}
+
 /* LLM thinking / bash comments — green */
 .term-think {
     color: #4ade80;

+ 21 - 6
src/pages/task_terminal.html

@@ -13,7 +13,7 @@
                 statum.addStateListener('terminal', function () {
                     var state = statum.read('terminal');
                     if (!connected && state && state.exists && state.session_id > 0) {
-                        if (window.initQuoteComments) {
+                        if (window.initQuoteComments && !state.has_raw_log) {
                             window.initQuoteComments({
                                 matches: function (node) {
                                     return node.closest && node.closest('#term-log') != null;
@@ -21,20 +21,34 @@
                                 action: function () { return state.comment; }
                             });
                         }
-                        connect(state.session_id);
+                        connect(state.session_id, !!state.has_raw_log);
                     }
                 });
 
-                function connect(sessionId) {
+                function connect(sessionId, rawMode) {
                     connected = true;
-                    var log = document.getElementById('term-log');
+                    var log = document.getElementById(rawMode ? 'term-raw' : 'term-log');
                     var source = new EventSource('/terminal-stream/' + sessionId);
                     var lastId = 0;
+                    var lastSeq = 0;
                     source.onmessage = function (event) {
                         if (event.data === 'ping') return;
                         var entry = null;
                         try { entry = JSON.parse(event.data); } catch (e) { return; }
-                        if (!entry || entry.id <= lastId) return;
+                        if (!entry) return;
+                        if (rawMode) {
+                            // Raw PTY log: one unstyled <pre> of concatenated
+                            // chunks, byte-exact as the daemon produced them.
+                            if (entry.type !== 'raw' || entry.seq <= lastSeq) return;
+                            lastSeq = entry.seq;
+                            var ph = log.querySelector('.term-placeholder');
+                            if (ph) ph.remove();
+                            var near = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
+                            log.appendChild(document.createTextNode(entry.text));
+                            if (near) log.scrollTop = log.scrollHeight;
+                            return;
+                        }
+                        if (entry.id <= lastId) return;
                         lastId = entry.id;
                         var placeholder = log.querySelector('.term-placeholder');
                         if (placeholder) placeholder.remove();
@@ -111,7 +125,8 @@
                 </span>
             </p>
 
-            <div class="terminal-plain" stm-if="terminal.sessions.length > 0" id="term-log">
+            <pre class="terminal-plain terminal-raw" stm-if="terminal.has_raw_log" id="term-raw"><span class="muted term-placeholder">Connecting…</span></pre>
+            <div class="terminal-plain" stm-if="terminal.sessions.length > 0 && !terminal.has_raw_log" id="term-log">
                 <span class="muted term-placeholder">Connecting…</span>
             </div>
             <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>

BIN
test/__pycache__/web_stack_test.cpython-314.pyc


+ 66 - 3
test/web_stack_test.py

@@ -151,6 +151,14 @@ def wait_task_done(auth, task_id, timeout=90):
                     timeout=timeout, what="task %d -> done" % task_id)
 
 
+def task_id_of(title):
+    conn = db()
+    try:
+        return conn.execute("select id from tasks where title = ?", (title,)).fetchone()[0]
+    finally:
+        conn.close()
+
+
 def saddle_api(method, path, token, payload=None):
     """Raw call against the saddle ingest API; returns (status, json)."""
     request = urllib.request.Request(WEB + path,
@@ -194,7 +202,10 @@ class FakeSaddle:
 
     def log_since(self, since):
         with self.lock:
-            chunks = [c for c in self.chunks if c["seq"] > since]
+            # `since` is the inclusive resume point (wire contract: a
+            # response ending at seq 12 carries next=13, and polling
+            # with since=13 must still deliver the chunk numbered 13).
+            chunks = [c for c in self.chunks if c["seq"] >= since]
             return chunks, self.next_seq
 
     class Handler(BaseHTTPRequestHandler):
@@ -246,9 +257,10 @@ class FakeSaddle:
             elif self.path.startswith("/log"):
                 query = urllib.parse.urlparse(self.path).query
                 since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
-                chunks, nxt = self.daemon.log_since(since)
-                # Simulate the live PTY: each poll produces one more chunk.
+                # Simulate the live PTY: each poll produces one more chunk
+                # (before slicing, so the poller actually sees it).
                 self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
+                chunks, nxt = self.daemon.log_since(since)
                 self._send(200, {"chunks": chunks, "next": nxt})
             else:
                 self._send(404, {"error": "no such route"})
@@ -562,6 +574,57 @@ def main():
         check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
               "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
               % (len(notes), total))
+
+        print("== raw log polling + terminal raw view ==")
+        # Wait for the last dispatch to finish so its final polls ran.
+        check(wait_task_done(auth, task_id_of("Char cap dispatch"), timeout=60) is not None,
+              "char-cap task completes")
+        conn = db()
+        raw_session = conn.execute(
+            "select s.id from sessions s join tasks t on t.id = s.task_id"
+            " where t.title = 'Char cap dispatch' order by s.id desc limit 1").fetchone()[0]
+        rows = conn.execute("select seq, chunk from session_raw_log where session_id = ?"
+                            " order by seq", (raw_session,)).fetchall()
+        conn.close()
+        check(len(rows) >= 3, "session_raw_log captured the daemon's chunks (%d)" % len(rows))
+        seqs = [r[0] for r in rows]
+        check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs),
+              "raw chunks stored once, seq strictly increasing")
+        text = "".join(r[1] for r in rows)
+        check(fake.PROMPT in text and "echo hello" in text and "hello" in text,
+              "raw log keeps the prompt and echoed output byte-exact")
+        check("stream-line-" in text, "incremental /log polls append later chunks")
+
+        terminal = slot_public(auth, "/tasks/%d/terminal" % task_id_of("Char cap dispatch"),
+                               "terminal")
+        check(terminal.get("has_raw_log") is True and int(terminal["session_id"]) == raw_session,
+              "terminal view selects raw rendering for the raw session")
+
+        # Old sessions (typed entries, no raw rows) keep the fallback view.
+        conn = db()
+        conn.execute("insert into session_commands (session_id, command_text, output_text,"
+                     " type, started_at) values (?, 'ls', 'old-output', 'command',"
+                     " strftime('%s','now'))", (session_id,))
+        conn.commit()
+        conn.close()
+        legacy = slot_public(auth, "/tasks/%d/terminal" % plan_task, "terminal")
+        check(legacy.get("has_raw_log") is False,
+              "legacy session falls back to the entry-type terminal view")
+
+        request = urllib.request.Request(WEB + "/terminal-stream/%d" % raw_session)
+        with urllib.request.urlopen(request, timeout=15) as stream:
+            raw_events = []
+            deadline = time.monotonic() + 8
+            while time.monotonic() < deadline and len(raw_events) < 3:
+                line = stream.readline().decode().strip()
+                if not line.startswith("data: ") or line == "data: ping":
+                    continue
+                event = json.loads(line[6:])
+                if event.get("type") == "raw":
+                    raw_events.append(event)
+            check(len(raw_events) >= 3 and raw_events[0]["text"] == fake.PROMPT
+                  and raw_events[0]["seq"] < raw_events[1]["seq"],
+                  "terminal stream serves raw chunks in seq order (prompt first)")
     finally:
         app.terminate()
         try: