Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #1205.
This commit is contained in:
@@ -86,6 +86,19 @@
|
|||||||
<p>Your task has been added to the queue. Timmy will review it shortly.</p>
|
<p>Your task has been added to the queue. Timmy will review it shortly.</p>
|
||||||
<button type="button" id="submit-another-btn" class="btn-primary">Submit Another</button>
|
<button type="button" id="submit-another-btn" class="btn-primary">Submit Another</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="submit-job-queued" class="submit-job-queued hidden">
|
||||||
|
<div class="queued-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Job Queued</h3>
|
||||||
|
<p>The server is unreachable right now. Your job has been saved locally and will be submitted automatically when the connection is restored.</p>
|
||||||
|
<div id="queue-count-display" class="queue-count-display"></div>
|
||||||
|
<button type="button" id="submit-another-queued-btn" class="btn-primary">Submit Another</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="submit-job-backdrop" class="submit-job-backdrop"></div>
|
<div id="submit-job-backdrop" class="submit-job-backdrop"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +155,7 @@
|
|||||||
import { createFamiliar } from "./familiar.js";
|
import { createFamiliar } from "./familiar.js";
|
||||||
import { setupControls } from "./controls.js";
|
import { setupControls } from "./controls.js";
|
||||||
import { StateReader } from "./state.js";
|
import { StateReader } from "./state.js";
|
||||||
|
import { messageQueue } from "./queue.js";
|
||||||
|
|
||||||
// --- Renderer ---
|
// --- Renderer ---
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
@@ -182,8 +196,60 @@
|
|||||||
moodEl.textContent = state.timmyState.mood;
|
moodEl.textContent = state.timmyState.mood;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Replay queued jobs whenever the server comes back online.
|
||||||
|
stateReader.onConnectionChange(async (online) => {
|
||||||
|
if (!online) return;
|
||||||
|
const pending = messageQueue.getPending();
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
console.log(`[queue] Online — replaying ${pending.length} queued job(s)`);
|
||||||
|
for (const item of pending) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(item.payload),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
messageQueue.markDelivered(item.id);
|
||||||
|
console.log(`[queue] Delivered queued job ${item.id}`);
|
||||||
|
} else {
|
||||||
|
messageQueue.markFailed(item.id);
|
||||||
|
console.warn(`[queue] Failed to deliver job ${item.id}: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Still offline — leave as QUEUED, will retry next cycle.
|
||||||
|
console.warn(`[queue] Replay aborted (still offline): ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageQueue.prune();
|
||||||
|
_updateQueueBadge();
|
||||||
|
});
|
||||||
|
|
||||||
stateReader.connect();
|
stateReader.connect();
|
||||||
|
|
||||||
|
// --- Queue badge (top-right indicator for pending jobs) ---
|
||||||
|
function _updateQueueBadge() {
|
||||||
|
const count = messageQueue.pendingCount();
|
||||||
|
let badge = document.getElementById("queue-badge");
|
||||||
|
if (count === 0) {
|
||||||
|
if (badge) badge.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement("div");
|
||||||
|
badge.id = "queue-badge";
|
||||||
|
badge.className = "queue-badge";
|
||||||
|
badge.title = "Jobs queued offline — will submit on reconnect";
|
||||||
|
document.getElementById("overlay").appendChild(badge);
|
||||||
|
}
|
||||||
|
badge.textContent = `${count} queued`;
|
||||||
|
}
|
||||||
|
// Show badge on load if there are already queued messages.
|
||||||
|
messageQueue.prune();
|
||||||
|
_updateQueueBadge();
|
||||||
|
|
||||||
// --- About Panel ---
|
// --- About Panel ---
|
||||||
const infoBtn = document.getElementById("info-btn");
|
const infoBtn = document.getElementById("info-btn");
|
||||||
const aboutPanel = document.getElementById("about-panel");
|
const aboutPanel = document.getElementById("about-panel");
|
||||||
@@ -228,6 +294,9 @@
|
|||||||
const descWarning = document.getElementById("desc-warning");
|
const descWarning = document.getElementById("desc-warning");
|
||||||
const submitJobSuccess = document.getElementById("submit-job-success");
|
const submitJobSuccess = document.getElementById("submit-job-success");
|
||||||
const submitAnotherBtn = document.getElementById("submit-another-btn");
|
const submitAnotherBtn = document.getElementById("submit-another-btn");
|
||||||
|
const submitJobQueued = document.getElementById("submit-job-queued");
|
||||||
|
const submitAnotherQueuedBtn = document.getElementById("submit-another-queued-btn");
|
||||||
|
const queueCountDisplay = document.getElementById("queue-count-display");
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const MAX_TITLE_LENGTH = 200;
|
const MAX_TITLE_LENGTH = 200;
|
||||||
@@ -255,6 +324,7 @@
|
|||||||
submitJobForm.reset();
|
submitJobForm.reset();
|
||||||
submitJobForm.classList.remove("hidden");
|
submitJobForm.classList.remove("hidden");
|
||||||
submitJobSuccess.classList.add("hidden");
|
submitJobSuccess.classList.add("hidden");
|
||||||
|
submitJobQueued.classList.add("hidden");
|
||||||
updateCharCounts();
|
updateCharCounts();
|
||||||
clearErrors();
|
clearErrors();
|
||||||
validateForm();
|
validateForm();
|
||||||
@@ -363,6 +433,7 @@
|
|||||||
submitJobBackdrop.addEventListener("click", closeSubmitJobModal);
|
submitJobBackdrop.addEventListener("click", closeSubmitJobModal);
|
||||||
cancelJobBtn.addEventListener("click", closeSubmitJobModal);
|
cancelJobBtn.addEventListener("click", closeSubmitJobModal);
|
||||||
submitAnotherBtn.addEventListener("click", resetForm);
|
submitAnotherBtn.addEventListener("click", resetForm);
|
||||||
|
submitAnotherQueuedBtn.addEventListener("click", resetForm);
|
||||||
|
|
||||||
// Input event listeners for real-time validation
|
// Input event listeners for real-time validation
|
||||||
jobTitle.addEventListener("input", () => {
|
jobTitle.addEventListener("input", () => {
|
||||||
@@ -420,9 +491,10 @@
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(formData)
|
body: JSON.stringify(formData),
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Show success state
|
// Show success state
|
||||||
submitJobForm.classList.add("hidden");
|
submitJobForm.classList.add("hidden");
|
||||||
@@ -433,9 +505,14 @@
|
|||||||
descError.classList.add("visible");
|
descError.classList.add("visible");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// For demo/development, show success even if API fails
|
// Server unreachable — persist to localStorage queue.
|
||||||
|
messageQueue.enqueue(formData);
|
||||||
|
const count = messageQueue.pendingCount();
|
||||||
submitJobForm.classList.add("hidden");
|
submitJobForm.classList.add("hidden");
|
||||||
submitJobSuccess.classList.remove("hidden");
|
submitJobQueued.classList.remove("hidden");
|
||||||
|
queueCountDisplay.textContent =
|
||||||
|
count > 1 ? `${count} jobs queued` : "1 job queued";
|
||||||
|
_updateQueueBadge();
|
||||||
} finally {
|
} finally {
|
||||||
submitJobSubmit.disabled = false;
|
submitJobSubmit.disabled = false;
|
||||||
submitJobSubmit.textContent = "Submit Job";
|
submitJobSubmit.textContent = "Submit Job";
|
||||||
|
|||||||
90
static/world/queue.js
Normal file
90
static/world/queue.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Offline message queue for Workshop panel.
|
||||||
|
*
|
||||||
|
* Persists undelivered job submissions to localStorage so they survive
|
||||||
|
* page refreshes and are replayed when the server comes back online.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const _QUEUE_KEY = "timmy_workshop_queue";
|
||||||
|
const _MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours — auto-expire old items
|
||||||
|
|
||||||
|
export const STATUS = {
|
||||||
|
QUEUED: "queued",
|
||||||
|
DELIVERED: "delivered",
|
||||||
|
FAILED: "failed",
|
||||||
|
};
|
||||||
|
|
||||||
|
function _load() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(_QUEUE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _save(items) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(_QUEUE_KEY, JSON.stringify(items));
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable — degrade silently */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _uid() {
|
||||||
|
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LocalStorage-backed message queue for Workshop job submissions. */
|
||||||
|
export const messageQueue = {
|
||||||
|
/** Add a payload. Returns the created item (with id and status). */
|
||||||
|
enqueue(payload) {
|
||||||
|
const item = {
|
||||||
|
id: _uid(),
|
||||||
|
payload,
|
||||||
|
queuedAt: new Date().toISOString(),
|
||||||
|
status: STATUS.QUEUED,
|
||||||
|
};
|
||||||
|
const items = _load();
|
||||||
|
items.push(item);
|
||||||
|
_save(items);
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Mark a message as delivered and remove it from storage. */
|
||||||
|
markDelivered(id) {
|
||||||
|
_save(_load().filter((i) => i.id !== id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Mark a message as permanently failed (kept for 24h for visibility). */
|
||||||
|
markFailed(id) {
|
||||||
|
_save(
|
||||||
|
_load().map((i) =>
|
||||||
|
i.id === id ? { ...i, status: STATUS.FAILED } : i
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** All messages waiting to be delivered. */
|
||||||
|
getPending() {
|
||||||
|
return _load().filter((i) => i.status === STATUS.QUEUED);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Total queued (QUEUED status only) count. */
|
||||||
|
pendingCount() {
|
||||||
|
return this.getPending().length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Drop expired failed items (> 24h old). */
|
||||||
|
prune() {
|
||||||
|
const cutoff = Date.now() - _MAX_AGE_MS;
|
||||||
|
_save(
|
||||||
|
_load().filter(
|
||||||
|
(i) =>
|
||||||
|
i.status === STATUS.QUEUED ||
|
||||||
|
(i.status === STATUS.FAILED &&
|
||||||
|
new Date(i.queuedAt).getTime() > cutoff)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Provides Timmy's current state to the scene. In Phase 2 this is a
|
* Provides Timmy's current state to the scene. In Phase 2 this is a
|
||||||
* static default; the WebSocket path is stubbed for future use.
|
* static default; the WebSocket path is stubbed for future use.
|
||||||
|
*
|
||||||
|
* Also manages connection health monitoring: pings /api/matrix/health
|
||||||
|
* every 30 seconds and notifies listeners when online/offline state
|
||||||
|
* changes so the Workshop can replay any queued messages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -20,11 +24,19 @@ const DEFAULTS = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _HEALTH_URL = "/api/matrix/health";
|
||||||
|
const _PING_INTERVAL_MS = 30_000;
|
||||||
|
const _WS_RECONNECT_DELAY_MS = 5_000;
|
||||||
|
|
||||||
export class StateReader {
|
export class StateReader {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = { ...DEFAULTS };
|
this.state = { ...DEFAULTS };
|
||||||
this.listeners = [];
|
this.listeners = [];
|
||||||
|
this.connectionListeners = [];
|
||||||
this._ws = null;
|
this._ws = null;
|
||||||
|
this._online = false;
|
||||||
|
this._pingTimer = null;
|
||||||
|
this._reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to state changes. */
|
/** Subscribe to state changes. */
|
||||||
@@ -32,7 +44,12 @@ export class StateReader {
|
|||||||
this.listeners.push(fn);
|
this.listeners.push(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify all listeners. */
|
/** Subscribe to online/offline transitions. Called with (isOnline: bool). */
|
||||||
|
onConnectionChange(fn) {
|
||||||
|
this.connectionListeners.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify all state listeners. */
|
||||||
_notify() {
|
_notify() {
|
||||||
for (const fn of this.listeners) {
|
for (const fn of this.listeners) {
|
||||||
try {
|
try {
|
||||||
@@ -43,8 +60,48 @@ export class StateReader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try to connect to the world WebSocket for live updates. */
|
/** Fire connection listeners only when state actually changes. */
|
||||||
connect() {
|
_notifyConnection(online) {
|
||||||
|
if (online === this._online) return;
|
||||||
|
this._online = online;
|
||||||
|
for (const fn of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
fn(online);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Connection listener error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ping the health endpoint once and update connection state. */
|
||||||
|
async _ping() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(_HEALTH_URL, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
this._notifyConnection(r.ok);
|
||||||
|
} catch {
|
||||||
|
this._notifyConnection(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start 30-second health-check loop (idempotent). */
|
||||||
|
_startHealthCheck() {
|
||||||
|
if (this._pingTimer) return;
|
||||||
|
this._pingTimer = setInterval(() => this._ping(), _PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule a WebSocket reconnect attempt after a delay (idempotent). */
|
||||||
|
_scheduleReconnect() {
|
||||||
|
if (this._reconnectTimer) return;
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._connectWS();
|
||||||
|
}, _WS_RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open (or re-open) the WebSocket connection. */
|
||||||
|
_connectWS() {
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const url = `${proto}//${location.host}/api/world/ws`;
|
const url = `${proto}//${location.host}/api/world/ws`;
|
||||||
try {
|
try {
|
||||||
@@ -52,10 +109,13 @@ export class StateReader {
|
|||||||
this._ws.onopen = () => {
|
this._ws.onopen = () => {
|
||||||
const dot = document.getElementById("connection-dot");
|
const dot = document.getElementById("connection-dot");
|
||||||
if (dot) dot.classList.add("connected");
|
if (dot) dot.classList.add("connected");
|
||||||
|
this._notifyConnection(true);
|
||||||
};
|
};
|
||||||
this._ws.onclose = () => {
|
this._ws.onclose = () => {
|
||||||
const dot = document.getElementById("connection-dot");
|
const dot = document.getElementById("connection-dot");
|
||||||
if (dot) dot.classList.remove("connected");
|
if (dot) dot.classList.remove("connected");
|
||||||
|
this._notifyConnection(false);
|
||||||
|
this._scheduleReconnect();
|
||||||
};
|
};
|
||||||
this._ws.onmessage = (ev) => {
|
this._ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,9 +135,18 @@ export class StateReader {
|
|||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("WebSocket unavailable — using static state");
|
console.warn("WebSocket unavailable — using static state");
|
||||||
|
this._scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Connect to the world WebSocket and start health-check polling. */
|
||||||
|
connect() {
|
||||||
|
this._connectWS();
|
||||||
|
this._startHealthCheck();
|
||||||
|
// Immediate ping so connection status is known before the first interval.
|
||||||
|
this._ping();
|
||||||
|
}
|
||||||
|
|
||||||
/** Current mood string. */
|
/** Current mood string. */
|
||||||
get mood() {
|
get mood() {
|
||||||
return this.state.timmyState.mood;
|
return this.state.timmyState.mood;
|
||||||
@@ -92,4 +161,9 @@ export class StateReader {
|
|||||||
get energy() {
|
get energy() {
|
||||||
return this.state.timmyState.energy;
|
return this.state.timmyState.energy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the server is currently reachable. */
|
||||||
|
get isOnline() {
|
||||||
|
return this._online;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,68 @@ canvas {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queued State (offline buffer) */
|
||||||
|
.submit-job-queued {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-job-queued.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: #ffaa33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-job-queued h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ffaa33;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-job-queued p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count-display {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffaa33;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queue badge — shown in overlay corner when offline jobs are pending */
|
||||||
|
.queue-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(10, 10, 20, 0.85);
|
||||||
|
border: 1px solid rgba(255, 170, 51, 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #ffaa33;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: queue-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes queue-pulse {
|
||||||
|
0%, 100% { opacity: 0.8; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.about-panel-content {
|
.about-panel-content {
|
||||||
|
|||||||
Reference in New Issue
Block a user