Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ Expects standard SolverForge REST endpoints:
- `DELETE /schedules/{id}` — stop solving
- `GET /demo-data/{name}` — load demo dataset

Backend contract expectations:
- `createSchedule()` must resolve to a plain schedule/job id (string), or an object containing one of `id`, `jobId`, `job_id`, `scheduleId`, or `schedule_id`.
- Events passed into `streamEvents()` for a job should include one of the same identifiers if multiple solver runs are possible.
- Tauri payloads are ignored only when they carry a different job id than the active run; id-less single-run updates still pass through.

### Tauri

```javascript
Expand Down
39 changes: 36 additions & 3 deletions js-src/10-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,35 @@
return createHttpBackend(config);
};

function resolveJobId(raw) {
if (raw == null) return '';
if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim();
if (typeof raw !== 'object') return '';

if (raw.id != null) return String(raw.id).trim();
if (raw.jobId != null) return String(raw.jobId).trim();
if (raw.job_id != null) return String(raw.job_id).trim();
if (raw.scheduleId != null) return String(raw.scheduleId).trim();
if (raw.schedule_id != null) return String(raw.schedule_id).trim();

if (raw.data && typeof raw.data === 'object' && raw.data.id != null) {
return String(raw.data.id).trim();
}
return '';
}

function resolveEventJobId(payload) {
if (!payload || typeof payload !== 'object') return '';
if (payload.jobId != null) return String(payload.jobId).trim();
if (payload.job_id != null) return String(payload.job_id).trim();
if (payload.scheduleId != null) return String(payload.scheduleId).trim();
if (payload.schedule_id != null) return String(payload.schedule_id).trim();
if (payload.id != null) return String(payload.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim();
return '';
}

/* ── HTTP backend (Axum, Rails, anything) ── */

function createHttpBackend(config) {
Expand All @@ -37,7 +66,7 @@

return {
createSchedule: function (data) {
return request('POST', schedulesPath, data);
return request('POST', schedulesPath, data).then(resolveJobId);
},
getSchedule: function (id) {
return request('GET', schedulesPath + '/' + id);
Expand Down Expand Up @@ -75,7 +104,7 @@

return {
createSchedule: function (data) {
return invoke(commands.startSolve || 'create_schedule', { request: data });
return invoke(commands.startSolve || 'create_schedule', { request: data }).then(resolveJobId);
},
getSchedule: function (id) {
return invoke(commands.getSchedule || 'get_schedule', { id: id });
Expand All @@ -93,9 +122,13 @@
return Promise.resolve([]);
},
streamEvents: function (id, onMessage) {
var targetId = String(id);
var unlisten = null;
listen(eventName, function (event) {
onMessage(event.payload);
var payload = event && event.payload ? event.payload : {};
var payloadId = resolveEventJobId(payload);
if (payloadId && payloadId !== targetId) return;
onMessage(payload);
Comment on lines 127 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't unconditionally drop Tauri events without top-level ids

This filter discards every Tauri update whose job id is not exposed in one of the top-level fields that resolveEventJobId() knows about. That breaks backends that emit id-less updates for a single active run, and even payload.data.id payloads, which isEventForCurrentJob() in js-src/11-solver.js still treats as valid. In those setups the UI never receives live progress or the final NOT_SOLVING event, so the solver appears to hang until it is manually stopped.

Useful? React with 👍 / 👎.

}).then(function (fn) { unlisten = fn; });
return function close() { if (unlisten) unlisten(); };
},
Expand Down
14 changes: 13 additions & 1 deletion js-src/11-solver.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@
}

backend.createSchedule(data).then(function (id) {
jobId = typeof id === 'string' ? id.trim() : id;
if (typeof id !== 'string' || !id.trim()) {
throw new Error('Invalid solver backend createSchedule response');
}
jobId = id;
closeStream = backend.streamEvents(jobId, function (msg) {
if (!isEventForCurrentJob(msg, jobId)) return;

// Solver finished
if (msg.solverStatus === 'NOT_SOLVING') {
backend.getSchedule(jobId).then(function (final) {
Expand Down Expand Up @@ -86,6 +91,13 @@
api.getJobId = function () { return jobId; };

return api;

function isEventForCurrentJob(msg, expectedId) {
if (!msg || typeof msg !== 'object') return false;
var candidate = msg.jobId || msg.job_id || msg.scheduleId || msg.schedule_id || msg.id || (msg.data && msg.data.id);
if (candidate == null) return true;
return String(candidate) === String(expectedId);
}
};

})(SF);
53 changes: 49 additions & 4 deletions static/sf/sf.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,35 @@ const SF = (function () {
return createHttpBackend(config);
};

function resolveJobId(raw) {
if (raw == null) return '';
if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim();
if (typeof raw !== 'object') return '';

if (raw.id != null) return String(raw.id).trim();
if (raw.jobId != null) return String(raw.jobId).trim();
if (raw.job_id != null) return String(raw.job_id).trim();
if (raw.scheduleId != null) return String(raw.scheduleId).trim();
if (raw.schedule_id != null) return String(raw.schedule_id).trim();

if (raw.data && typeof raw.data === 'object' && raw.data.id != null) {
return String(raw.data.id).trim();
}
return '';
}

function resolveEventJobId(payload) {
if (!payload || typeof payload !== 'object') return '';
if (payload.jobId != null) return String(payload.jobId).trim();
if (payload.job_id != null) return String(payload.job_id).trim();
if (payload.scheduleId != null) return String(payload.scheduleId).trim();
if (payload.schedule_id != null) return String(payload.schedule_id).trim();
if (payload.id != null) return String(payload.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim();
return '';
}

/* ── HTTP backend (Axum, Rails, anything) ── */

function createHttpBackend(config) {
Expand All @@ -779,7 +808,7 @@ const SF = (function () {

return {
createSchedule: function (data) {
return request('POST', schedulesPath, data);
return request('POST', schedulesPath, data).then(resolveJobId);
},
getSchedule: function (id) {
return request('GET', schedulesPath + '/' + id);
Expand Down Expand Up @@ -817,7 +846,7 @@ const SF = (function () {

return {
createSchedule: function (data) {
return invoke(commands.startSolve || 'create_schedule', { request: data });
return invoke(commands.startSolve || 'create_schedule', { request: data }).then(resolveJobId);
},
getSchedule: function (id) {
return invoke(commands.getSchedule || 'get_schedule', { id: id });
Expand All @@ -835,9 +864,13 @@ const SF = (function () {
return Promise.resolve([]);
},
streamEvents: function (id, onMessage) {
var targetId = String(id);
var unlisten = null;
listen(eventName, function (event) {
onMessage(event.payload);
var payload = event && event.payload ? event.payload : {};
var payloadId = resolveEventJobId(payload);
if (payloadId && payloadId !== targetId) return;
onMessage(payload);
}).then(function (fn) { unlisten = fn; });
return function close() { if (unlisten) unlisten(); };
},
Expand Down Expand Up @@ -872,8 +905,13 @@ const SF = (function () {
}

backend.createSchedule(data).then(function (id) {
jobId = typeof id === 'string' ? id.trim() : id;
if (typeof id !== 'string' || !id.trim()) {
throw new Error('Invalid solver backend createSchedule response');
}
jobId = id;
closeStream = backend.streamEvents(jobId, function (msg) {
if (!isEventForCurrentJob(msg, jobId)) return;

// Solver finished
if (msg.solverStatus === 'NOT_SOLVING') {
backend.getSchedule(jobId).then(function (final) {
Expand Down Expand Up @@ -933,6 +971,13 @@ const SF = (function () {
api.getJobId = function () { return jobId; };

return api;

function isEventForCurrentJob(msg, expectedId) {
if (!msg || typeof msg !== 'object') return false;
var candidate = msg.jobId || msg.job_id || msg.scheduleId || msg.schedule_id || msg.id || (msg.data && msg.data.id);
if (candidate == null) return true;
return String(candidate) === String(expectedId);
}
};

})(SF);
Expand Down
96 changes: 96 additions & 0 deletions tests/backend-contract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');

const { createDom } = require('./support/fake-dom');

const ROOT = path.resolve(__dirname, '..');

function loadSf(files, overrides = {}) {
const { document, window, Node } = createDom();
const context = vm.createContext({
console,
document,
window,
Node,
Promise,
setTimeout,
clearTimeout,
...overrides,
});

files.forEach((file) => {
const source = fs.readFileSync(path.join(ROOT, file), 'utf8');
vm.runInContext(source, context, { filename: file });
});

return { SF: context.window.SF, document };
}

test('tauri createSchedule normalizes object and numeric ids to strings', async () => {
const calls = [];
const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], {
fetch() {
throw new Error('unexpected fetch');
},
});

const backendWithObject = SF.createBackend({
type: 'tauri',
invoke(command, payload) {
calls.push({ command, payload });
return Promise.resolve({ jobId: 42 });
},
listen() {
return Promise.resolve(function () {});
},
});

assert.equal(await backendWithObject.createSchedule({ foo: 'bar' }), '42');
assert.equal(calls[0].command, 'create_schedule');

const backendWithNumber = SF.createBackend({
type: 'tauri',
invoke() {
return Promise.resolve(7);
},
listen() {
return Promise.resolve(function () {});
},
});

assert.equal(await backendWithNumber.createSchedule({}), '7');
});

test('tauri streamEvents keeps id-less updates and filters mismatched job ids', async () => {
let handler = null;
const received = [];
const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js']);

const backend = SF.createBackend({
type: 'tauri',
invoke() {
return Promise.resolve('job-1');
},
listen(_eventName, onEvent) {
handler = onEvent;
return Promise.resolve(function () {});
},
});

backend.streamEvents('job-1', function (payload) {
received.push(payload);
});

await Promise.resolve();

handler({ payload: { score: '0hard/0soft' } });
handler({ payload: { data: { id: 'job-1' }, solverStatus: 'SOLVING_ACTIVE' } });
handler({ payload: { jobId: 'job-2', solverStatus: 'NOT_SOLVING' } });

assert.equal(received.length, 2);
assert.equal(received[0].score, '0hard/0soft');
assert.equal(received[1].data.id, 'job-1');
});
Loading