Merge remote-tracking branch 'origin/dev' into codex/fix-pr241-ci

This commit is contained in:
excho0 committed 2026-10-14 19:30:55 +03:00
commit 74d0e31474
20 files changed
+434 -44

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
name: Something is broken
description: Sisyphus did something it should not, or did not do something it should.
labels: [bug]
body:
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead. Paste any error exactly.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: How to make it happen again
placeholder: |
1. sisyphusd run --coordinator ...
2. sisyphusd submit primes ...
- type: dropdown
id: part
attributes:
label: Which part
multiple: true
options:
- The node (sisyphusd)
- The desktop app
- The planner or models
- The MCP server or skill
- Storage or IPFS
- Installing or building
- type: input
id: version
attributes:
label: Version or commit
description: "`sisyphusd version`, or the commit you built from."
- type: input
id: system
attributes:
label: System
placeholder: Windows 11, RTX 4050, Ollama 0.21
- type: textarea
id: logs
attributes:
label: Logs
description: The node's output around the problem. Remove anything private first.
render: text
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: Ask a question
url: https://github.com/sisyphus-network/Sisyphus/discussions/categories/q-a
about: How to run a node, lend a machine, or use the desktop app. Questions get answered in Discussions.
- name: Share an idea
url: https://github.com/sisyphus-network/Sisyphus/discussions/categories/ideas
about: Something Sisyphus could do. Ideas start in Discussions and become issues once there is a plan.
- name: Report a vulnerability
url: https://github.com/sisyphus-network/Sisyphus/security/policy
about: Please report security problems privately, not in a public issue.
+23
View File
@@ -0,0 +1,23 @@
name: A planned piece of work
description: Something to build, once it has been talked through. Open ideas belong in Discussions first.
labels: [enhancement]
body:
- type: textarea
id: why
attributes:
label: Why
description: What someone cannot do today, or what goes wrong.
validations:
required: true
- type: textarea
id: what
attributes:
label: What
description: What would be built, and how we will know it works.
validations:
required: true
- type: input
id: discussion
attributes:
label: Discussion
description: A link to where this was talked through, if anywhere.
+11
View File
@@ -0,0 +1,11 @@
## What and why
<!-- What this changes, and the issue it closes: "Closes #N". -->
## How it was checked
<!-- The tests added, and anything run by hand: on which machines, with what result. -->
- [ ] `make cover` passes (every statement tested) for daemon changes
- [ ] `npm run typecheck`, `npm test` and `npm run build` pass for desktop changes
- [ ] The README, `apps/sisyphusd/README.md` and `docs/` say what changed
+23 -8
View File
@@ -14,6 +14,8 @@ jobs:
# from this repository and pushes to dev use the project's Linux runner.
runs-on: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'ubuntu-latest' || 'sisyphus-ci' }}
env:
# All three self-hosted runners share the same host loopback and ephemeral-port space.
SISYPHUS_CI_LOCAL_NETWORK_LOCK: /tmp/sisyphus-ci-local-network.lock
# Fail, rather than skip, any test that finds no ipfs program.
SISYPHUS_REQUIRE_KUBO: "1"
# Likewise any that finds it cannot use Docker.
@@ -67,14 +69,23 @@ jobs:
- run: go vet ./...
- name: Check for mistakes vet does not look for
run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
- run: go test -race ./...
- name: Test against real local services
# Each Go package test process has its own test-only port allocator.
# Keep packages sequential as well as keeping this step exclusive with
# other localhost-heavy workflows on the shared host.
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" go test -race -p 1 ./...
- name: Check every statement is tested
run: |
if command -v make >/dev/null 2>&1; then
make cover
else
nix develop --command make cover
fi
flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" bash -euo pipefail -c '
# Coverage runs the same integration tests as above. Keep their
# package processes sequential so test-only ports cannot overlap.
export GOFLAGS="-p=1"
if command -v make >/dev/null 2>&1; then
make cover
else
nix develop --command make cover
fi
'
- run: go build ./apps/sisyphusd
# A pool across machines, as near as one runner gets: each node in a
@@ -99,6 +110,9 @@ jobs:
defaults:
run:
working-directory: apps/sisyphus
env:
# Electron's test node binds loopback ports just like the Go integration suite.
SISYPHUS_CI_LOCAL_NETWORK_LOCK: /tmp/sisyphus-ci-local-network.lock
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
@@ -119,9 +133,10 @@ jobs:
- name: Build matching daemon for desktop lifecycle acceptance
working-directory: .
run: go build -o bin/sisyphusd ./apps/sisyphusd
- run: npm run test:daemon
- name: Test desktop daemon lifecycle
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" npm run test:daemon
- run: npm run build
# The real app, built above, driven in a window nobody sees against
# the daemon built above, on a node and a profile of the tests' own.
- name: End-to-end tests of the desktop app
run: npm run test:e2e
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" npm run test:e2e
+9
View File
@@ -0,0 +1,9 @@
# Code of conduct
Sisyphus adopts the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) as its code of conduct. It applies in the repository, its issues, pull requests and discussions, and wherever someone speaks for the project.
In short: be considerate, assume good faith, criticise work and not people, and leave room for those who are new. Harassment, personal attacks and unwelcome sexual attention are not accepted in any form.
## Reporting
If someone's conduct concerns you, tell a maintainer privately: [@rvnminers-A-and-N](https://github.com/rvnminers-A-and-N) or [@excho0](https://github.com/excho0). Reports are kept confidential. The maintainers decide what happens, following the Covenant's [enforcement guidelines](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines), from a private word up to a ban from the project.
+1 -1
View File
@@ -1,6 +1,6 @@
# Contributing to Sisyphus
Sisyphus is an early prototype built in the open. Issues, questions and pull requests are welcome.
Sisyphus is an early prototype built in the open. Issues, questions and pull requests are welcome. Questions and early ideas go to [Discussions](https://github.com/sisyphus-network/Sisyphus/discussions); bugs and planned work go to issues. Everyone taking part follows the [code of conduct](CODE_OF_CONDUCT.md).
## Before you start
+16 -1
View File
@@ -4,6 +4,13 @@
<h1 align="center">Sisyphus</h1>
<p align="center">
<a href="https://github.com/sisyphus-network/Sisyphus/actions/workflows/ci.yml"><img src="https://github.com/sisyphus-network/Sisyphus/actions/workflows/ci.yml/badge.svg?branch=dev" alt="CI" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue" alt="Apache 2.0 license" /></a>
<a href="https://github.com/sisyphus-network/Sisyphus/discussions"><img src="https://img.shields.io/github/discussions/sisyphus-network/Sisyphus" alt="Discussions" /></a>
<a href="https://github.com/sisyphus-network/Sisyphus/issues/23"><img src="https://img.shields.io/badge/roadmap-%2323-8a2be2" alt="Roadmap" /></a>
</p>
**Sisyphus is a decentralized network for useful compute.** Independent participants contribute CPU and GPU capacity, and users and AI agents submit computational work to the network. Its planned economic layer uses SISY to coordinate incentives, supply, and demand.
AI makes the network easier to use: describe a problem, and Sisyphus can plan the work, find suitable capacity, coordinate execution, inspect the results, and decide what to compute next.
@@ -244,7 +251,7 @@ The development shell includes Go, gopls, Delve, lint tools, Protocol Buffer gen
## Project status
Sisyphus is an early prototype. The network loop works without AI: a coordinator splits a job, workers on other nodes run the tasks, and the results are combined. Everything so far has run on one machine as separate processes; it has not yet been tried across physically separate machines.
Sisyphus is an early prototype. The network loop works without AI: a coordinator splits a job, workers on other nodes run the tasks, and the results are combined. It has run across physically separate machines on a home network, a Linux server and a Windows laptop with a graphics card, splitting a job between them and serving a language model from the card ([#18](https://github.com/sisyphus-network/Sisyphus/issues/18), [#87](https://github.com/sisyphus-network/Sisyphus/issues/87)).
**Working today**, in `sisyphusd`, written in Go:
@@ -272,6 +279,8 @@ Sisyphus is an early prototype. The network loop works without AI: a coordinator
- Jobs made of steps, each a job of its own that can use the results of those before it.
- Opt-in verification by replication: a job can ask that each of its tasks be run by several different workers and take a result only when they agree. A worker that returns something else is outvoted and named. It is for deterministic work, and does not stop workers that agree to lie. With spot checks only a share of the tasks, picked at random, is verified, and a worker that is caught has its other tasks in the job verified after all. A coordinator can count workers at one address as one, and can check a share of any job's tasks by running them again itself, which needs no second worker.
- The node's local API over Connect and gRPC-Web as well as gRPC, so that a page in a browser can use it.
- The desktop app's interface served by a node to a browser, which is paired with the node once by a one-time code and can be revoked device by device.
- Several named model providers kept by a node, each with its own key, which never leaves the node.
- A desktop app that shows a node and its pool, plans with it and admits or removes workers, packaged with the logo as a Windows installer, a macOS disk image and a Linux AppImage.
- A first real workload, `transcode`: a video is cut into stretches, encoded by ffmpeg on different workers, and joined.
- Container images as jobs: a worker whose owner allows it runs any image, one copy for each task, with its output stored and its log followed live.
@@ -284,6 +293,12 @@ Try it with `make demo`, or see [Trying it](#trying-it) below. [`apps/sisyphusd/
Two things in this section go beyond what the sections above describe and are still open for discussion: the choice of Go ([#1](https://github.com/sisyphus-network/Sisyphus/issues/1)), and building storage on IPFS formats while V0 lists IPFS as out of scope ([#25](https://github.com/sisyphus-network/Sisyphus/issues/25)).
## Community
- **[Discussions](https://github.com/sisyphus-network/Sisyphus/discussions)**: questions, ideas, and what you have run on a pool.
- **[Issues](https://github.com/sisyphus-network/Sisyphus/issues)**: bugs and planned work. The [board](https://github.com/orgs/sisyphus-network/projects/1) shows what is in progress.
- **[Contributing](CONTRIBUTING.md)**, the [code of conduct](CODE_OF_CONDUCT.md), and [reporting a vulnerability](SECURITY.md).
## License
Sisyphus is licensed under the [Apache License, Version 2.0](LICENSE). Contributions are accepted under the same license.
+11 -7
View File
@@ -355,8 +355,13 @@ test('the topology canvas stays fitted through live window resizes', async (t) =
let observedBufferResize = false
for (const [nextWidth, nextHeight] of [[1040, 720], [1280, 900], [980, 760], [1180, 800]]) {
await app.evaluate(({ BrowserWindow }, [w, h]) => BrowserWindow.getAllWindows()[0].setSize(w, h), [nextWidth, nextHeight])
await page.waitForFunction(([w, h]) => Math.abs(window.innerWidth - w) < 30 && Math.abs(window.innerHeight - h) < 80, [nextWidth, nextHeight])
const previous = await canvas.evaluate((element) => ({ width: element.width, height: element.height }))
await app.evaluate(({ BrowserWindow }, [w, h]) => BrowserWindow.getAllWindows()[0].setContentSize(w, h), [nextWidth, nextHeight])
await page.waitForFunction(([w, h]) => Math.abs(window.innerWidth - w) < 5 && Math.abs(window.innerHeight - h) < 5, [nextWidth, nextHeight])
await page.waitForFunction(([width, height]) => {
const canvasEl = document.querySelector('.network-globe-stage canvas')
return canvasEl && (canvasEl.width !== width || canvasEl.height !== height)
}, [previous.width, previous.height])
await page.waitForFunction(() => {
const stageEl = document.querySelector('.network-globe-stage')
const canvasEl = stageEl?.querySelector('canvas')
@@ -405,14 +410,13 @@ test('the workspace switches layouts live across tablet and desktop widths', asy
const touch = await page.context().newCDPSession(page)
await touch.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.55), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.waitForTimeout(300)
assert.equal(await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).count(), 0, 'a swipe should not switch views; mobile navigation is dock-driven')
await dockButtons.nth(1).click()
await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).waitFor()
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.45), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.65), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await dockButtons.nth(0).click()
await page.locator('.workspace-mobile-pane[data-active="true"] textarea').waitFor()
const composer = page.locator('.workspace-mobile-pane[data-active="true"] textarea')
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
export type LiquidDockItem = {
@@ -27,12 +27,46 @@ export function LiquidGlassDock({
const activeIndex = items.findIndex((item) => item.active);
const visualIndex = activeIndex >= 0 ? activeIndex : 0;
const [previousIndex, setPreviousIndex] = useState(visualIndex);
const dockRef = useRef<HTMLFieldSetElement>(null);
const activeRef = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const dock = dockRef.current;
const active = activeRef.current;
if (!dock || !active) return;
let frame = 0;
const measure = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const option = dock.querySelectorAll<HTMLElement>(".liquid-dock__option")[visualIndex];
if (!option) return;
const dockBounds = dock.getBoundingClientRect();
const optionBounds = option.getBoundingClientRect();
// Keep the selected glass pill visually substantial as the dock gains
// items, while centering it on the actual option and staying in bounds.
const width = optionBounds.width + (items.length > 4 ? 8 : 0);
const centeredRight = dockBounds.right - optionBounds.right - (width - optionBounds.width) / 2;
active.style.setProperty("--liquid-width", `${width}px`);
active.style.setProperty("--liquid-offset", `${centeredRight}px`);
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(dock);
dock.querySelectorAll(".liquid-dock__option").forEach((option) => observer.observe(option));
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [items.length, visualIndex]);
return (
<div className={`liquid-dock-wrap ${className ?? ""}`} dir="rtl">
<fieldset className="liquid-dock" aria-label={label} data-active={activeIndex >= 0} data-previous={previousIndex}>
<fieldset ref={dockRef} className="liquid-dock" aria-label={label} data-active={activeIndex >= 0} data-previous={previousIndex}>
<legend className="sr-only">{label}</legend>
<span aria-hidden="true" className="liquid-dock__active" style={{ "--liquid-index": visualIndex } as CSSProperties} />
<span ref={activeRef} aria-hidden="true" className="liquid-dock__active" style={{ "--liquid-index": visualIndex } as CSSProperties} />
{items.map((item) => (
<label key={item.id} className="liquid-dock__option" data-active={item.active ? "true" : "false"}>
<input className="liquid-dock__input" type="radio" name={`sisyphus-navigation-${label}`} checked={item.active} readOnly aria-label={item.label} />
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type TouchEvent } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
import { createPortal } from 'react-dom'
import { Eye, MessageSquare } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
@@ -288,7 +288,6 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const topologyDragRef = useRef<{ pointerId: number; startX: number; startY: number; startWidth: number; startRatio: number; startOpen: boolean; dragging: boolean } | null>(null)
const topologyLastTapRef = useRef<{ at: number; x: number; y: number } | null>(null)
const [globeReady, setGlobeReady] = useState(false)
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
useEffect(() => {
if (compact || historyResizing || topologyResizing) return
// What was chosen for the topology is kept as it was while it cannot be
@@ -499,14 +498,7 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
<div className="max-w-[62%] rounded-lg border border-[var(--app-line)] bg-[var(--app-card)]/85 px-3 py-2 text-end text-muted-foreground shadow-sm backdrop-blur"><span className="me-1.5 inline-block size-1.5 rounded-full bg-[var(--app-accent)] align-middle" />{messages.thisNode}{countryFlag(countryCode) ? ` · ${countryFlag(countryCode)}` : ''}{countryPosition ? <span className="ms-1.5">{countryCode}</span> : ''}</div>
</div>
</section>
const handleTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
if (touchStart === null) return
const deltaX = event.changedTouches[0].clientX - touchStart.x
const deltaY = event.changedTouches[0].clientY - touchStart.y
if (topologyAvailable && Math.abs(deltaX) > 55 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) setMobileView(deltaX < 0 ? 'network' : 'chat')
setTouchStart(null)
}
return <div className={`workspace-shell ${compact ? 'workspace-shell--compact' : ''}`} onTouchStart={(event) => setTouchStart({ x: event.touches[0].clientX, y: event.touches[0].clientY })} onTouchEnd={handleTouchEnd}>
return <div className={`workspace-shell ${compact ? 'workspace-shell--compact' : ''}`}>
{compact ? <>
<div className="workspace-mobile-stage">
<motion.div aria-hidden={mobileView !== 'chat'} className="workspace-mobile-pane" data-active={mobileView === 'chat'} animate={reduceMotion ? undefined : { opacity: mobileView === 'chat' ? 1 : 0, x: mobileView === 'chat' ? 0 : -24 }} transition={reduceMotion ? { duration: 0 } : { type: 'tween', duration: 0.24, ease: [0.22, 1, 0.36, 1] }} style={{ zIndex: mobileView === 'chat' ? 2 : 1, pointerEvents: mobileView === 'chat' ? 'auto' : 'none' }}>
@@ -56,8 +56,7 @@
.workspace-mobile-dock .liquid-dock-wrap { width: min(244px, 100%); margin: 0; pointer-events: auto; }
.workspace-mobile-dock .liquid-dock { width: 100%; height: 70px; padding: 8px 12px 10px; }
.workspace-mobile-dock .liquid-dock__option { flex: 1 1 0; width: auto; padding-inline: 0; }
.workspace-mobile-dock .liquid-dock__active { right: 12px; width: calc((100% - 32px) / 2); transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))); }
.workspace-mobile-dock .liquid-dock[data-active="false"] .liquid-dock__active { transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))) scale(.82,.9); }
.workspace-mobile-dock .liquid-dock__active { right: 0; }
.workspace-chat { min-height: 0; padding-inline: 1rem; }
.workspace-shell--compact { touch-action: pan-y; }
html:has(.app-shell--workspace),
@@ -163,7 +162,7 @@
.app-shell--advanced { min-height: 100dvh; }
.app-main { flex: 1 1 auto; min-height: 0; }
.app-mobile-dock { display: none; }
@media (min-width: 761px) {
@media (min-width: 901px) {
.app-shell--advanced { display: flex; flex-direction: row; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
.app-shell-body--advanced { width: 100%; min-height: 0; overflow: hidden; }
.app-content-column--advanced { height: 100%; overflow: hidden; }
@@ -179,7 +178,7 @@
.settings-page__heading { flex: 0 0 auto; }
.settings-page__scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: 28px; }
}
@media (max-width: 760px) {
@media (max-width: 900px) {
.app-shell--advanced { position: relative; display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
.app-shell-body--advanced { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
.app-content-column--advanced { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
@@ -190,14 +189,67 @@
.app-mobile-dock .liquid-dock-wrap { width: min(280px, 100%); margin: 0; pointer-events: auto; }
.app-mobile-dock .liquid-dock { width: 100%; }
.app-mobile-dock .liquid-dock__option { flex: 1 1 0; min-width: 0; width: auto; padding-inline: 0; }
.app-mobile-dock .liquid-dock__active { right: 12px; width: calc((100% - 48px) / 4); transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))); }
.app-mobile-dock .liquid-dock[data-active="false"] .liquid-dock__active { transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))) scale(.82,.9); }
.app-mobile-dock .liquid-dock__active { right: 0; }
}
@media (prefers-reduced-motion: reduce) {
.workspace-mobile-pane { will-change: auto; backface-visibility: visible; }
}
/* Advanced navigation and the paired-device table share the app's accent system. */
.advanced-sidebar { box-sizing: border-box; display: flex; width: 248px; flex: 0 0 248px; flex-direction: column; gap: 26px; border-inline-end: 1px solid color-mix(in srgb, var(--app-accent) 14%, var(--app-line)); background: linear-gradient(165deg, color-mix(in srgb, var(--app-accent) 6%, var(--app-sidebar)), var(--app-sidebar) 34%); padding: 22px 14px 16px; }
.app-shell--advanced .advanced-sidebar { width: 248px; flex-basis: 248px; }
.advanced-sidebar__brand { display: flex; min-width: 0; min-height: 62px; align-items: center; justify-content: center; border: 0; background: transparent; padding: 4px; }
.advanced-sidebar__logo { display: block; width: 64px; height: 64px; flex: 0 0 64px; object-fit: contain; filter: drop-shadow(0 0 18px color-mix(in srgb, var(--app-accent) 26%, transparent)); }
.advanced-sidebar__nav { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 24px; }
.advanced-sidebar__group { display: grid; gap: 5px; }
.advanced-sidebar__label { padding: 0 10px 5px; color: var(--app-muted); font-size: 9px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
.advanced-sidebar__item { position: relative; display: flex; height: 43px; min-width: 0; align-items: center; gap: 11px; overflow: hidden; border: 1px solid transparent; border-radius: 13px; padding: 0 10px; color: var(--app-muted); font-size: 12px; text-decoration: none; transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease; }
.advanced-sidebar__item:hover { transform: translateX(2px); background: color-mix(in srgb, var(--app-accent) 6%, var(--app-card)); color: var(--app-ink); }
.advanced-sidebar__item.is-active { border-color: color-mix(in srgb, var(--app-accent) 19%, var(--app-line)); background: linear-gradient(100deg, color-mix(in srgb, var(--app-accent) 13%, var(--app-card)), color-mix(in srgb, var(--app-accent) 5%, var(--app-card))); color: var(--app-ink); box-shadow: inset 0 1px 0 color-mix(in srgb, white 13%, transparent), 0 5px 15px color-mix(in srgb, var(--app-accent) 7%, transparent); }
.advanced-sidebar__item.is-active::after { position: absolute; inset-block: 10px; inset-inline-start: 0; width: 3px; border-radius: 0 5px 5px 0; background: var(--app-accent); content: ""; box-shadow: 0 0 12px color-mix(in srgb, var(--app-accent) 55%, transparent); }
.advanced-sidebar__item-icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 9px; background: color-mix(in srgb, var(--app-ink) 4%, transparent); transition: background 180ms ease, color 180ms ease; }
.advanced-sidebar__item.is-active .advanced-sidebar__item-icon { background: color-mix(in srgb, var(--app-accent) 16%, transparent); color: var(--app-accent); }
.advanced-sidebar__status { position: relative; isolation: isolate; overflow: hidden; border: 0; border-radius: 17px; background: radial-gradient(ellipse at 5% 0%, color-mix(in srgb, var(--app-accent) 18%, transparent), transparent 68%), linear-gradient(145deg, color-mix(in srgb, var(--app-card) 88%, transparent), color-mix(in srgb, var(--app-sidebar) 94%, transparent)); padding: 14px 12px 12px; box-shadow: inset 0 1px 0 color-mix(in srgb, white 10%, transparent), 0 12px 28px color-mix(in srgb, var(--app-ink) 10%, transparent); }
.advanced-sidebar__status-glow { position: absolute; z-index: -1; inset-inline-end: -28px; top: -34px; width: 92px; height: 92px; border-radius: 50%; background: color-mix(in srgb, var(--app-accent) 13%, transparent); filter: blur(25px); pointer-events: none; }
.advanced-sidebar__status-dot { position: relative; width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--app-muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-muted) 10%, transparent); }
.advanced-sidebar__status-dot.is-online { background: #20c997; box-shadow: 0 0 0 4px color-mix(in srgb, #20c997 13%, transparent), 0 0 14px color-mix(in srgb, #20c997 50%, transparent); }
.advanced-sidebar__status-dot.is-connecting { background: #f4b942; animation: sidebar-status-pulse 1.5s ease-in-out infinite; }
.advanced-sidebar__endpoint { border-radius: 8px; background: color-mix(in srgb, var(--app-bg) 50%, transparent); padding: 7px 8px; }
.advanced-sidebar__peer-metric { border-radius: 10px; background: color-mix(in srgb, var(--app-accent) 6%, transparent); padding: 8px 9px; }
@keyframes sidebar-status-pulse { 50% { opacity: .5; box-shadow: 0 0 0 7px color-mix(in srgb, #f4b942 8%, transparent); } }
.devices-layout { display: flex; flex-direction: column; }
.devices-section { order: -1; }
.devices-table-wrap { overflow: hidden; border: 1px solid var(--app-line); border-radius: 17px; background: color-mix(in srgb, var(--app-card) 90%, transparent); box-shadow: 0 10px 30px color-mix(in srgb, var(--app-ink) 4%, transparent); }
.devices-table { width: 100%; border-collapse: collapse; text-align: start; }
.devices-table th { height: 43px; background: color-mix(in srgb, var(--app-wash) 72%, transparent); padding: 0 17px; color: var(--app-muted); font-size: 9px; font-weight: 700; letter-spacing: .1em; text-align: start; text-transform: uppercase; }
.devices-table td { border-top: 1px solid var(--app-line); padding: 12px 17px; vertical-align: middle; }
.devices-table__actions { width: 1%; white-space: nowrap; text-align: end !important; }
.devices-empty { border-style: dashed; background: color-mix(in srgb, var(--app-card) 72%, transparent); }
.devices-pair-card { width: 100%; }
@media (min-width: 901px) { .advanced-sidebar__item:hover { transform: translateX(2px); } }
@media (max-width: 900px) {
.advanced-sidebar, .app-shell--advanced .advanced-sidebar { width: 205px; flex-basis: 205px; padding-inline: 10px; }
.devices-table-wrap { overflow: visible; border: 0; background: transparent; box-shadow: none; }
.devices-table, .devices-table tbody { display: grid; width: 100%; gap: 11px; }
.devices-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
.devices-table tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 9px 14px; border: 1px solid var(--app-line); border-radius: 16px; background: color-mix(in srgb, var(--app-card) 94%, transparent); padding: 13px; box-shadow: 0 8px 22px color-mix(in srgb, var(--app-ink) 4%, transparent); }
.devices-table td { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border: 0; padding: 0; }
.devices-table td:first-child { grid-column: 1 / -1; }
.devices-table td:nth-child(2) { grid-column: 1; }
.devices-table td:nth-child(2)::before { color: var(--app-muted); content: attr(data-label); font-size: 9px; }
.devices-table td.devices-table__actions { grid-column: 2; grid-row: 2; }
.devices-table__actions { width: auto; }
.devices-table td.devices-table__actions::before { content: none; }
.devices-table td:nth-child(2) time { text-align: end; }
}
@media (max-width: 520px) {
.devices-table tr { grid-template-columns: minmax(0, 1fr); }
.devices-table td.devices-table__actions { grid-column: 1; grid-row: auto; justify-content: flex-end; border-top: 1px solid var(--app-line); padding-top: 9px; }
.devices-table td:nth-child(2) { gap: 5px; align-items: flex-start; flex-direction: column; }
.devices-table td:nth-child(2) time { text-align: start; }
}
.network-visual {
isolation: isolate;
background: transparent;
+12
View File
@@ -77,6 +77,18 @@ bin/sisyphusd pool join --addr <coordinator-ip>:7700 <invitation> # on the oth
bin/sisyphusd nodes --addr <coordinator-ip>:7700
```
**Watching the work.** Each node's own output says what it is doing for the pool, so a pool can be followed from any of its machines:
```text
coordinator msg="task given" task=34b7…/0 worker=laptop attempt=1
laptop msg="task started" job=34b7… task=34b7…/0 workload=chat attempt=1 model=llama3.1:8b
laptop msg="task done" job=34b7… task=34b7…/0 took=8.076s output_bytes=283
coordinator msg="task returned" task=34b7…/0 worker=laptop took=8.079s
coordinator msg="job finished" job=34b7… state=succeeded took=8.95s by="laptop×1"
```
A task that fails says why on the worker (`task failed … error=…`); one the coordinator took back says `task stopped`. A private job's tasks are logged without the model they ask for. `-v` adds the lower-level detail.
## Who can connect
Every connection between nodes is encrypted, and each end proves who it is.
+11 -2
View File
@@ -209,11 +209,20 @@ func readKey(file string) (sealed.Key, error) {
// keyCommand makes sealing keys.
func keyCommand(args []string) error {
if len(args) != 2 || args[0] != "new" {
if len(args) == 0 || args[0] != "new" {
return errors.New(`expected "key new <file>"`)
}
// Parsed as flags, so that --help is asked for help and not taken for
// the name of a file to put a key in.
fs := flag.NewFlagSet("sisyphusd key new <file>", flag.ContinueOnError)
if err := fs.Parse(args[1:]); err != nil {
return err
}
if fs.NArg() != 1 {
return errors.New(`expected "key new <file>"`)
}
// Created, never overwritten: replacing a key loses whatever it sealed.
file, err := os.OpenFile(args[1], os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
file, err := os.OpenFile(fs.Arg(0), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create key file: %w", err)
}
+28 -1
View File
@@ -4,6 +4,7 @@
package coordinator
import (
"cmp"
"context"
"crypto/rand"
"encoding/hex"
@@ -12,6 +13,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"slices"
"sort"
"strings"
@@ -1115,6 +1117,7 @@ func (c *Coordinator) handleResult(w *worker, result *pb.TaskResult) {
}
switch outcome := result.GetOutcome().(type) {
case *pb.TaskResult_Output:
c.log.Info("task returned", "task", a.task.ID, "worker", w.name, "took", now.Sub(a.started).Round(time.Millisecond).String())
// What the task stored must outlive the hour a new blob is kept
// for, since the job may run longer than that. Only what the store
// holds is taken as stored: the worker's word for it is not enough.
@@ -1192,11 +1195,34 @@ func (c *Coordinator) aggregate(job *jobmodel.Job) {
job.NoteResult(touched.Written()...)
job.Finish(result, err, now)
c.settleLocked(job)
c.log.Info("job finished", "job", job.ID, "state", job.State.String(), "took", now.Sub(job.CreatedAt).String())
c.log.Info("job finished", "job", job.ID, "state", job.State.String(), "took", now.Sub(job.CreatedAt).String(), "by", workedBy(job))
c.notifyLocked(job)
c.scheduleLocked()
}
// workedBy says which workers did a job's tasks and how many each did, as
// "laptop×12 r620×20", most first.
func workedBy(job *jobmodel.Job) string {
counts := map[string]int{}
for _, task := range job.Tasks {
if task.NodeName != "" {
counts[task.NodeName]++
}
}
names := slices.Collect(maps.Keys(counts))
slices.SortFunc(names, func(a, b string) int {
if c := cmp.Compare(counts[b], counts[a]); c != 0 {
return c
}
return strings.Compare(a, b)
})
said := make([]string, len(names))
for i, name := range names {
said[i] = fmt.Sprintf("%s×%d", name, counts[name])
}
return strings.Join(said, " ")
}
// settleLocked sets how long a job's blobs are kept once it is over: its
// inputs and results for the retention period, and what only passed between
// its tasks no longer. It does nothing while the job is still going.
@@ -1419,6 +1445,7 @@ func (c *Coordinator) scheduleLocked() {
// handLocked gives a worker an attempt at a task.
func (c *Coordinator) handLocked(w *worker, a assignment) {
c.recordLocked(a.job, eventTaskStarted, a.task.Index, w.name, fmt.Sprintf("attempt %d", a.attempt))
c.log.Info("task given", "task", a.task.ID, "worker", w.name, "attempt", a.attempt)
w.running[a.task.ID] = a
keys := c.keys[a.job.ID]
w.send.add(&pb.CoordinatorMessage{Kind: &pb.CoordinatorMessage_Assignment{Assignment: &pb.TaskAssignment{
+22
View File
@@ -0,0 +1,22 @@
package coordinator
import (
"testing"
jobmodel "github.com/sisyphus-network/Sisyphus/packages/job-model"
)
// A finished job's line in the coordinator's log says which machines did
// its tasks, those that did most first.
func TestAJobSaysWhichWorkersDidItsTasks(t *testing.T) {
job := &jobmodel.Job{}
for _, name := range []string{"r620", "laptop", "r620", "", "rig", "laptop", "r620", "box"} {
job.Tasks = append(job.Tasks, &jobmodel.Task{NodeName: name})
}
if got, want := workedBy(job), "r620×3 laptop×2 box×1 rig×1"; got != want {
t.Errorf("workedBy = %q, want %q", got, want)
}
if got := workedBy(&jobmodel.Job{}); got != "" {
t.Errorf("a job no worker touched was worked by %q", got)
}
}
+10
View File
@@ -3,6 +3,8 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"io"
"os"
"path/filepath"
@@ -192,6 +194,14 @@ func TestKeyNewMakesAKeyFileAndNeverReplacesOne(t *testing.T) {
t.Errorf("%v: error %v", args, err)
}
}
// Asking for help gets help, and no file named for the asking.
t.Chdir(t.TempDir())
if _, err := cli(t, "key", "new", "--help"); !errors.Is(err, flag.ErrHelp) {
t.Errorf("key new --help: %v, want help", err)
}
if _, err := os.Stat("--help"); !os.IsNotExist(err) {
t.Errorf("key new --help made a file: %v", err)
}
}
func TestPrivateJobFromTheCommandLine(t *testing.T) {
+7 -3
View File
@@ -293,7 +293,7 @@ func TestDaemonStopsWhenItsCoordinatorRemovesIt(t *testing.T) {
}
}
func TestVerboseDaemonLogsEachTask(t *testing.T) {
func TestADaemonLogsEachTaskItGivesAndRuns(t *testing.T) {
logs := captureLogs(t)
addr := freeAddr(t)
stop := startDaemon(t, "-v", "--listen", addr, "--slots", "1")
@@ -303,8 +303,12 @@ func TestVerboseDaemonLogsEachTask(t *testing.T) {
}
stop()
if !strings.Contains(logs.String(), `level=DEBUG msg="task done"`) {
t.Errorf("no per-task debug line in the log:\n%s", logs)
// The worker says what it ran, and the coordinator whom it gave each
// task to and, at the end, who did how many.
for _, want := range []string{`level=INFO msg="task started"`, `level=INFO msg="task done"`, `msg="task given"`, `msg="task returned"`, `msg="job finished"`, `by=`} {
if !strings.Contains(logs.String(), want) {
t.Errorf("no %s in the log:\n%s", want, logs)
}
}
}
@@ -0,0 +1,70 @@
package worker
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
pb "github.com/sisyphus-network/Sisyphus/packages/protocol/sisyphus/v1"
"github.com/sisyphus-network/Sisyphus/packages/runtime"
)
// asking is a workload whose tasks need a model, as a conversation's do,
// and that does what its payload says: answers, fails, or waits to be
// stopped.
type asking struct{}
func (asking) Name() string { return "asking" }
func (asking) Split(context.Context, runtime.Blobs, []byte, int) ([][]byte, error) {
return nil, nil
}
func (asking) Aggregate(context.Context, runtime.Blobs, [][]byte) ([]byte, error) { return nil, nil }
func (asking) Needs([]byte) []string { return []string{"model:llama3.1:8b"} }
func (asking) Execute(ctx context.Context, _ runtime.Blobs, payload []byte) ([]byte, error) {
switch string(payload) {
case "fail":
return nil, errors.New("out of memory")
case "wait":
<-ctx.Done()
return nil, ctx.Err()
}
return []byte("an answer"), nil
}
// A worker's own log says what it is doing for the pool: each task as it
// starts, with the model it asks for, and how it ended.
func TestAWorkerLogsEachTaskItIsGivenAndHowItEnded(t *testing.T) {
log := new(lines)
w := &Worker{Name: "laptop", Workloads: runtime.NewRegistry(asking{}, runtime.Primes{}), Log: slog.New(slog.NewTextHandler(log, nil))}
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j1", TaskId: "j1/0", Workload: "asking", Payload: []byte("answer")})
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j1", TaskId: "j1/1", Workload: "asking", Payload: []byte("fail")})
stopped, stop := context.WithCancel(context.Background())
stop()
w.execute(stopped, &pb.TaskAssignment{JobId: "j1", TaskId: "j1/2", Workload: "asking", Payload: []byte("wait")})
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j2", TaskId: "j2/0", Workload: "primes", Payload: []byte(`{"from":1,"to":10}`)})
// A private job's task names no model, even to its worker's log.
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j3", TaskId: "j3/0", Workload: "asking", Payload: []byte("answer"), Keys: []*pb.SealingKey{{}}})
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j4", TaskId: "j4/0", Workload: "nothing known"})
said := strings.Join(log.said, "")
for _, want := range []string{
`msg="task started" job=j1 task=j1/0 workload=asking attempt=0 model=llama3.1:8b`,
`msg="task done" job=j1 task=j1/0 took=`,
`output_bytes=9`,
`level=WARN msg="task failed" job=j1 task=j1/1`,
`error="out of memory"`,
`msg="task stopped" job=j1 task=j1/2`,
`msg="task started" job=j2 task=j2/0 workload=primes attempt=0` + "\n",
`msg="task started" job=j3 task=j3/0 workload=asking attempt=0` + "\n",
`msg="task failed" job=j4 task=j4/0`,
} {
if !strings.Contains(said, want) {
t.Errorf("the log has no %q:\n%s", want, said)
}
}
if strings.Contains(said, "task=j4/0 workload") {
t.Errorf("a task of a workload the worker does not have was said to start:\n%s", said)
}
}
+26 -2
View File
@@ -273,6 +273,10 @@ func (w *Worker) swarmChanged(fingerprint string) {
func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.TaskResult) {
result = &pb.TaskResult{TaskId: a.GetTaskId(), Attempt: a.GetAttempt()}
began := time.Now()
// Each task the worker is given shows in its own log as it ends, so
// that its owner sees what their machine is doing for the pool.
defer func() { w.logOutcome(ctx, a, result, time.Since(began)) }()
fail := func(err any) {
result.Outcome = &pb.TaskResult_Error{Error: fmt.Sprint(err)}
}
@@ -288,6 +292,15 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
fail(err)
return result
}
started := []any{"job", a.GetJobId(), "task", a.GetTaskId(), "workload", a.GetWorkload(), "attempt", a.GetAttempt()}
// The model a task asks for is named, but not for a private job, whose
// owner may not want even that in a worker's log.
if needy, ok := workload.(runtime.Needy); ok && !private(a) {
if model := runtime.ModelNeeded(needy.Needs(a.GetPayload())); model != "" {
started = append(started, "model", model)
}
}
w.Log.Info("task started", started...)
if w.Limit != nil {
if !w.Limit.Acquire(ctx, w.Pool) {
fail(ctx.Err())
@@ -295,7 +308,6 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
}
defer w.Limit.Release(w.Pool)
}
started := time.Now()
touched := runtime.Record(w.Blobs)
// A job that is not private opens no sealed blob: it has no key to one.
blobs := runtime.Open(touched)
@@ -315,11 +327,23 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
return result
}
result.ReadBlobs, result.WrittenBlobs = touched.Read(), touched.Written()
w.Log.Debug("task done", "node", w.Name, "task", a.GetTaskId(), "took", time.Since(started).String())
result.Outcome = &pb.TaskResult_Output{Output: output}
return result
}
// logOutcome says in the worker's log how a task it was given ended.
func (w *Worker) logOutcome(ctx context.Context, a *pb.TaskAssignment, result *pb.TaskResult, took time.Duration) {
said := []any{"job", a.GetJobId(), "task", a.GetTaskId(), "took", took.Round(time.Millisecond).String()}
switch {
case result.GetError() == "":
w.Log.Info("task done", append(said, "output_bytes", len(result.GetOutput()))...)
case ctx.Err() != nil:
w.Log.Info("task stopped", said...)
default:
w.Log.Warn("task failed", append(said, "error", result.GetError())...)
}
}
func (w *Worker) capabilities() *pb.NodeCapabilities {
hostname, _ := os.Hostname()
capabilities := &pb.NodeCapabilities{