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>
|
||||
<button type="button" id="submit-another-btn" class="btn-primary">Submit Another</button>
|
||||
</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 id="submit-job-backdrop" class="submit-job-backdrop"></div>
|
||||
</div>
|
||||
@@ -142,6 +155,7 @@
|
||||
import { createFamiliar } from "./familiar.js";
|
||||
import { setupControls } from "./controls.js";
|
||||
import { StateReader } from "./state.js";
|
||||
import { messageQueue } from "./queue.js";
|
||||
|
||||
// --- Renderer ---
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
@@ -182,8 +196,60 @@
|
||||
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();
|
||||
|
||||
// --- 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 ---
|
||||
const infoBtn = document.getElementById("info-btn");
|
||||
const aboutPanel = document.getElementById("about-panel");
|
||||
@@ -228,6 +294,9 @@
|
||||
const descWarning = document.getElementById("desc-warning");
|
||||
const submitJobSuccess = document.getElementById("submit-job-success");
|
||||
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
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
@@ -255,6 +324,7 @@
|
||||
submitJobForm.reset();
|
||||
submitJobForm.classList.remove("hidden");
|
||||
submitJobSuccess.classList.add("hidden");
|
||||
submitJobQueued.classList.add("hidden");
|
||||
updateCharCounts();
|
||||
clearErrors();
|
||||
validateForm();
|
||||
@@ -363,6 +433,7 @@
|
||||
submitJobBackdrop.addEventListener("click", closeSubmitJobModal);
|
||||
cancelJobBtn.addEventListener("click", closeSubmitJobModal);
|
||||
submitAnotherBtn.addEventListener("click", resetForm);
|
||||
submitAnotherQueuedBtn.addEventListener("click", resetForm);
|
||||
|
||||
// Input event listeners for real-time validation
|
||||
jobTitle.addEventListener("input", () => {
|
||||
@@ -420,9 +491,10 @@
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
body: JSON.stringify(formData),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
// Show success state
|
||||
submitJobForm.classList.add("hidden");
|
||||
@@ -433,9 +505,14 @@
|
||||
descError.classList.add("visible");
|
||||
}
|
||||
} 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");
|
||||
submitJobSuccess.classList.remove("hidden");
|
||||
submitJobQueued.classList.remove("hidden");
|
||||
queueCountDisplay.textContent =
|
||||
count > 1 ? `${count} jobs queued` : "1 job queued";
|
||||
_updateQueueBadge();
|
||||
} finally {
|
||||
submitJobSubmit.disabled = false;
|
||||
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
|
||||
* 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 = {
|
||||
@@ -20,11 +24,19 @@ const DEFAULTS = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const _HEALTH_URL = "/api/matrix/health";
|
||||
const _PING_INTERVAL_MS = 30_000;
|
||||
const _WS_RECONNECT_DELAY_MS = 5_000;
|
||||
|
||||
export class StateReader {
|
||||
constructor() {
|
||||
this.state = { ...DEFAULTS };
|
||||
this.listeners = [];
|
||||
this.connectionListeners = [];
|
||||
this._ws = null;
|
||||
this._online = false;
|
||||
this._pingTimer = null;
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
|
||||
/** Subscribe to state changes. */
|
||||
@@ -32,7 +44,12 @@ export class StateReader {
|
||||
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() {
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
@@ -43,8 +60,48 @@ export class StateReader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Try to connect to the world WebSocket for live updates. */
|
||||
connect() {
|
||||
/** Fire connection listeners only when state actually changes. */
|
||||
_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 url = `${proto}//${location.host}/api/world/ws`;
|
||||
try {
|
||||
@@ -52,10 +109,13 @@ export class StateReader {
|
||||
this._ws.onopen = () => {
|
||||
const dot = document.getElementById("connection-dot");
|
||||
if (dot) dot.classList.add("connected");
|
||||
this._notifyConnection(true);
|
||||
};
|
||||
this._ws.onclose = () => {
|
||||
const dot = document.getElementById("connection-dot");
|
||||
if (dot) dot.classList.remove("connected");
|
||||
this._notifyConnection(false);
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
this._ws.onmessage = (ev) => {
|
||||
try {
|
||||
@@ -75,9 +135,18 @@ export class StateReader {
|
||||
};
|
||||
} catch (e) {
|
||||
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. */
|
||||
get mood() {
|
||||
return this.state.timmyState.mood;
|
||||
@@ -92,4 +161,9 @@ export class StateReader {
|
||||
get energy() {
|
||||
return this.state.timmyState.energy;
|
||||
}
|
||||
|
||||
/** Whether the server is currently reachable. */
|
||||
get isOnline() {
|
||||
return this._online;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +604,68 @@ canvas {
|
||||
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 */
|
||||
@media (max-width: 480px) {
|
||||
.about-panel-content {
|
||||
|
||||
Reference in New Issue
Block a user