Помощники ведут лист
До десяти внешних участников, каждый в своём процессе: ходит по своей колонке, выделяет участок и вписывает итог — намерениями через тот же канал; отказ в лишнем виден и ему, и человеку; не запустили — страница так и говорит.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!doctype html><!-- ПОМОЩНИКИ ВЕДУТ ЛИСТ — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе целиком. Каждый помощник живёт в СВОЁМ соседнем процессе (воркер): своя память, свой модуль и ни одной общей переменной со страницей — до листа он достаёт ровно через канал, и никак иначе.
СКОЛЬКО ИХ — ПАРАМЕТР АДРЕСА (`?assistants=10`), а не константа: список и слой курсоров обязаны держать и десятерых.
ПОКА ИХ НЕ ЗАПУСТИЛИ, СТРАНИЦА ГОВОРИТ ЭТО ПРЯМО. Нарисованный помощник, которого нет, — это ровно тот fake-green, ради запрета которого написан инвариант №3.--><html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Помощники ведут лист — @f1</title> </head> <body> <div data-example="driver" class="flex h-screen w-screen flex-col bg-base-100 text-base-content"> <header class="flex items-start gap-3 border-b border-base-300 px-4 py-2"> <div class="min-w-0 flex-1"> <div class="text-sm font-semibold">Помощники ведут лист</div> <div class="text-xs opacity-70"> Помощники — программы в СОСЕДНИХ процессах — присутствуют здесь так же, как люди: своё имя, свой цвет, свой курсор. Ведут они не мышью, а НАМЕРЕНИЯМИ: решает их наш композиционный корень, и правка идёт тем же путём, что у ваших пальцев. Каждому разрешено писать и звать одну команду; всё остальное — отказ, и отказ вы увидите. </div> </div> <div class="flex shrink-0 flex-col items-end gap-1"> <!-- СКОЛЬКО ИХ — ССЫЛКАМИ НА ТЕ ЖЕ АДРЕСА (`?assistants=10`), а не кнопками со состоянием в памяти: на любое состояние показа можно дать ссылку, и обход ходит туда же, куда человек. --> <div class="join" data-driver-size></div> <button type="button" class="btn btn-primary btn-sm" data-driver-connect> Подключить помощников </button> <button type="button" class="btn btn-ghost btn-sm" data-driver-overreach disabled> Попросить лишнего </button> </div> </header>
<!-- ЧЕСТНОСТЬ НА ЭКРАНЕ: строка говорит то, что видно КАНАЛУ, а не то, нажимали ли кнопку. Помощник, упавший при запуске, обязан считаться неподключённым — иначе страница врёт ровно тогда, когда это дороже всего. --> <!-- РОСТ СТРОКИ НЕ ДВИГАЕТ ЛИСТ: меняется ТОЛЬКО короткое начало (`data-driver-said`), а объяснение стоит здесь с первой отрисовки и не зависит от того, подключены помощники или нет. Раньше сюда писалась целиком то короткая, то длинная строка: на 1280 и 1440 она переносилась на вторую строку в момент подключения, и лист уезжал вниз на 16,8 px под рукой человека (замер закреплён проверкой «ЛИСТ НЕ УЕЗЖАЕТ ПОД РУКОЙ»). Это то же правило, что и у подчёркивания активной вкладки: показ состояния не двигает соседей. --> <div class="border-b border-base-300 px-4 py-1 text-xs" data-driver-status> <span data-driver-said>Помощники не подключены.</span> Каждый ведёт СВОЮ колонку: ходит по ней, выделяет участок и вписывает итог. Книга здесь одна, поэтому их правки видны; согласования правок между разными книгами у нас нет — см. пример «двое в одном документе». </div>
<!-- ВКЛАДКА-ПУЛЬТ (`?role=drive`) ЛИСТА НЕ ДЕРЖИТ: у неё нет книги вовсе, и пустая рамка на её месте показывала бы документ, которого здесь не открыто. Говорит она это прямо — по тому же правилу, по которому страница говорит «помощники не подключены». --> <div class="hidden border-b border-base-300 px-4 py-2 text-xs" data-driver-role> Эта вкладка — ПУЛЬТ ПОМОЩНИКОВ, листа на ней нет. Помощники ведут лист, открытый в другом окружении, через ретранслятор: намерение уезжает по сокету, приговор приезжает обратно и виден ниже. </div>
<!-- ПРИГОВОРЫ ГЛАЗАМИ ВЕДУЩЕГО. Человеку у листа отказ показывает сам лист; здесь видно то, что услышала ДРУГАЯ сторона, — включая случай, которого до провода не существовало: ответа нет вовсе. --> <!-- МЕСТО ЗАНЯТО ЗАРАНЕЕ, А НЕ ПО МЕРЕ ПОЯВЛЕНИЯ ПРИГОВОРОВ. Высота ПОСТОЯННАЯ (`h-28`), а не `max-h-32`: журнал стоит НАД листом в том же столбце, и каждая новая строка сдвигала лист вниз на 16,8 px — за девять секунд работы одного помощника набегало 50,4 px, то есть три строки листа. Человек щёлкал B7, смотрел на помощников, щёлкал туда же и попадал в B4 — это замерено.
Почему здесь ЗАРЕЗЕРВИРОВАННОЕ МЕСТО, а не оверлей, как у полосы отказов: журнал — не показ состояния поверх листа, а вторая половина этой страницы, читать её человек будет постоянно; оверлей закрыл бы ячейки ровно тогда, когда приговоров много. Пустая рамка при этом подписана — иначе она выглядит поломкой, а не заготовленным местом. --> <div class="shrink-0 px-4 py-1" data-driver-log> <div class="text-xs opacity-50">Приговоры помощников</div> <ul class="h-28 overflow-auto" data-driver-verdicts></ul> </div>
<div class="min-h-0 flex-1 p-2" data-driver-sheet> <div class="flex h-full min-h-0 flex-col rounded-card border border-base-300"> <div class="seat flex min-h-0 flex-1 flex-col"></div> </div> </div> </div>
<script type="module" src="./src/main.ts"></script> </body></html>Точка входа: здесь лист создаётся и сюда же приходят события.
import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";import { PRESENCE_TIMING, broadcastLink, socketLink, type DriveGrant, type Peer, type DriveVerdict, type PeerId, type PresenceLink,} from "@f1/presence";import { book } from "./book.js";import { crewOf, crewSize } from "./crew.js";import { hostAsks } from "./host.js";import type { AssistantBrief, FromAssistant } from "./assistant.worker.js";import "./styles.css";
/** * ПОМОЩНИКИ ВЕДУТ ЛИСТ — И НИ ОДНОГО ВЫДУМАННОГО УЧАСТНИКА. * * Пример отвечает на два вопроса сразу: может ли программа-помощник вести * лист через сокет так, чтобы её курсор был виден человеку, и что происходит, * когда таких помощников не один, а десять. * * ПОМОЩНИК ЖИВЁТ ЗА ПРЕДЕЛАМИ ЭТОЙ СТРАНИЦЫ. Канал * выбирается адресом (`?link=socket&relay=…`), а `?role=drive` открывает вкладку * БЕЗ ЛИСТА — только помощники и их приговоры. Значит дорога проверяется целиком: * лист в одном окружении, ведущий в другом, между ними настоящий сокет и наш * ретранслятор. Ни строки продукта от этого не изменилось — сменилась одна * реализация порта. * * ЧТО ЗДЕСЬ ПРОИСХОДИТ. Каждый помощник живёт в СВОЁМ СОСЕДНЕМ ПРОЦЕССЕ * (воркер): своя память, свой модуль, ни одной общей переменной с этой * страницей. Он присутствует так же, как человек — своим именем, цветом и * курсором, — и ВЕДЁТ лист намерениями. Решает их наш композиционный корень: * правка идёт тем же путём, что у пальцев человека, и приходит в тот же транспорт * контракта. * * ЧТО ЗДЕСЬ ОБЩЕЕ, А ЧТО НЕТ, — И ЭТО НАПИСАНО НА ЭКРАНЕ. Книга ОДНА, и * помощники ведут именно её: поэтому их правки видны — не потому, что мы * что-то передали между сеансами. Согласования правок между РАЗНЫМИ книгами у * нас нет, и пример его не изображает (см. `?example=presence`, где две рамки * действительно не видят правок друг друга). Лучший ответ здесь — сказать * прямо, а не подделать. * * ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Никакой языковой модели по сети продукт не зовёт: * участник приходит через канал, кем бы он ни был. Поставьте на место воркера * что угодно — свой сервис, чужую программу, настоящую модель — договор не * изменится ни на строку. * * ПОКА ПОМОЩНИКОВ НЕ ЗАПУСТИЛИ, СТРАНИЦА ГОВОРИТ ЭТО ПРЯМО, и говорит по * КАНАЛУ, а не по факту нажатия кнопки: помощник, упавший при запуске, обязан * считаться неподключённым. Нарисованный участник, которого нет, — это * fake-green (инвариант №3), и здесь он был бы самым соблазнительным. */const { locale, link: kind, relay, role, surface: askedSurface } = hostAsks();
const SPACE = "пример-помощника";
const HUMAN: Peer = { id: "human", name: "Анна Петрова" };
const CREW = crewOf(crewSize(location.search));const CREW_IDS = new Set<PeerId>(CREW.map((member) => member.id));
/** * ЧТО ПОМОЩНИКАМ МОЖНО — РЕШАЕТ ХОЗЯИН, И ПОИМЁННО. * * Писать — да: за этим их и позвали. Команды — ОДНА, названная: «можно всё» * означало бы, что помощник умеет снять защиту и закрыть книгу, а узнали бы мы * об этом по последствиям. Кто не в составе — не ведёт ничего (`null`), и это * умолчание, а не забывчивость: канал знает тот, кто знает ключ, и права * ведения не должны следовать из знания ключа. */const grantFor = (from: PeerId): DriveGrant | null => CREW_IDS.has(from) ? { write: true, commands: ["clipboard.copy"] } : null;
/* КАНАЛ ВЫБИРАЕТ ХОЗЯИН СТРАНИЦЫ, а не лист и не помощник: продукт видит порт, а не сокет (`PresenceLink`). Ту же строку выбора делает вкладка помощников — и делает её СВОИМ кодом, потому что она другая программа. */const linkOf = (): PresenceLink => kind === "socket" ? socketLink({ space: SPACE, url: relay }) : broadcastLink({ space: SPACE });
const link = linkOf();
/* ЛИСТ ЕСТЬ НЕ ВСЕГДА: `?role=drive` — это пульт помощников, у него книги нет вовсе. Нарисовать пустую рамку значило бы показать документ, которого на этой вкладке не открыто. */const core: SheetsCore | null = role === "sheet" ? createSheetsCore({ mount: document.querySelector<HTMLElement>(".seat")!, transport: book(), locale, rows: 200, cols: 26, presence: { link, self: HUMAN, drive: { grantFor } }, }) : null;
if (role === "drive") { document.querySelector("[data-driver-sheet]")?.remove(); document.querySelector("[data-driver-role]")?.classList.remove("hidden");}
const status = document.querySelector<HTMLElement>("[data-driver-status]")!;/** * МЕНЯЕТСЯ ТОЛЬКО КОРОТКОЕ НАЧАЛО СТРОКИ, а объяснение стоит в разметке всегда. * * Не ради краткости кода: полная строка при подключении становилась вчетверо * длиннее, переносилась на вторую строку (на 1280 и 1440 — замер) и толкала лист * вниз на 16,8 px в тот самый момент, когда человек смотрит на помощников. * Правило набора: показ состояния не двигает геометрию соседей. */const said = document.querySelector<HTMLElement>("[data-driver-said]")!;const connect = document.querySelector<HTMLButtonElement>("[data-driver-connect]")!;const overreach = document.querySelector<HTMLButtonElement>("[data-driver-overreach]")!;const size = document.querySelector<HTMLElement>("[data-driver-size]")!;const verdicts = document.querySelector<HTMLElement>("[data-driver-verdicts]")!;
connect.textContent = `Подключить помощников (${CREW.length})`;/* ЧИСЛО — ССЫЛКАМИ НА ТЕ ЖЕ АДРЕСА, а не кнопками с состоянием в памяти: вид, который нельзя ни показать, ни воспроизвести, не показывает ничего. Тем же правилом живут тема и язык витрины. */for (const count of [1, 4, 10]) { const link = document.createElement("a"); const params = new URLSearchParams(location.search); params.set("assistants", String(count)); link.href = `?${params.toString()}`; link.className = `btn btn-xs${count === CREW.length ? " btn-active" : " btn-ghost"}`; link.textContent = String(count); link.dataset.driverSize = String(count); size.append(link);}
/** * ЧЕСТНОСТЬ СТРОКИ ДЕРЖИТСЯ КАНАЛОМ, А НЕ ПАМЯТЬЮ О НАЖАТИИ. * * Страница слушает ТОТ ЖЕ канал и считает помощника подключённым ровно пока от * него приходят вести — по тем же временам, по которым его считает живым список * присутствующих (`PRESENCE_TIMING.forgetAfterMs`). Переменной «мы их * запустили» здесь нет намеренно: она осталась бы правдой и после того, как * помощник упал. */const heardAt = new Map<PeerId, number>();link.listen((message) => { if (message.kind !== "join" && message.kind !== "state") return; if (!CREW_IDS.has(message.state.peer.id)) return; heardAt.set(message.state.peer.id, Date.now()); say();});
function say() { const now = Date.now(); const here = [...heardAt.values()].filter((seen) => now - seen < PRESENCE_TIMING.forgetAfterMs); said.textContent = here.length === 0 ? "Помощники не подключены." : `Помощников на листе: ${here.length} из ${CREW.length}.`; status.dataset.driverHere = here.length > 0 ? "yes" : "no"; status.dataset.driverCount = String(here.length); overreach.disabled = here.length === 0;}say();setInterval(say, PRESENCE_TIMING.moveThrottleMs * 4);
/** * ЧТО УСЛЫШАЛ САМ ВЕДУЩИЙ — И ПОЧЕМУ ЭТО ВИДНО ЗДЕСЬ, А НЕ ТОЛЬКО В ЖУРНАЛЕ. * * Человеку РЯДОМ С ЛИСТОМ отказ показывает сам лист (полоса отказов), и это * так и задумано. А вот сторона ВЕДУЩЕГО не показывала ничего: приговоры * уходили в консоль. Пока провод был внутри страницы, разницы не было; по сети * появился случай, которого раньше не существовало, — ретранслятор упал, и * приговора нет вовсе. Тишина и «отказано» выглядят на консоли одинаково: никак. * * Поэтому приговоры видны: `ok` тоже, а не только отказы. Список отказов без * согласий говорил бы, что помощник работает только когда ошибается. */const VERDICTS_SHOWN = 12;
function note(verdict: DriveVerdict) { const line = document.createElement("li"); line.dataset.driverVerdict = verdict.ok ? "ok" : (verdict.why ?? "refused"); line.className = `text-xs ${verdict.ok ? "opacity-70" : "text-error"}`; line.textContent = verdict.ok ? `${verdict.id}: принято` : `${verdict.id}: ${verdict.why}${verdict.detail ? ` — ${verdict.detail}` : ""}`; verdicts.prepend(line); /* ЖУРНАЛ НЕ РАСТЁТ БЕЗ КОНЦА. Помощники работают, пока открыта страница (по одному итогу за проход, ~17 приговоров в минуту на каждого — замер), и неограниченный список за час превратился бы в десятки тысяч узлов. Видно последнее: чем кончилось только что. */ while (verdicts.childElementCount > VERDICTS_SHOWN) verdicts.lastElementChild?.remove();}
const crew: Worker[] = [];
connect.addEventListener("click", () => { if (crew.length > 0) return; /* ИМЯ ЛИСТА — ОТ ТОГО, КТО ЗНАЕТ, ЧТО ОТКРЫТО: у вкладки с книгой это сама книга, у пульта — тот, кто его открыл (`?surface=…`). Помощник не гадает. */ const surface = core ? core.f1.getActiveWorkbook().getActiveSheet()!.getName() : askedSurface; for (const member of CREW) { /* ЗАПУСКАЕТ ИХ ХОЗЯИН СТРАНИЦЫ, а не лист: чужой процесс — не наша забота и не наше право. Лист узнаёт о помощнике ровно тогда, когда тот объявится в канале, — как и о любом другом участнике. */ const worker = new Worker(new URL("./assistant.worker.js", import.meta.url), { type: "module" }); worker.addEventListener("message", (event: MessageEvent<FromAssistant>) => { /* Приговоры печатаются в журнал только для наглядности: человеку их показывает сам лист полосой отказов. */ if (event.data.kind === "verdict") { console.info("[помощник]", event.data.verdict); note(event.data.verdict); } }); worker.postMessage({ kind: "start", space: SPACE, /* КАНАЛ ПОМОЩНИКУ НАЗЫВАЕТ ТОТ, КТО ЕГО ЗАПУСТИЛ: своего представления о том, где искать комнату, у чужой программы нет. */ link: kind, relay, /* Имя листа помощник не выдумывает: ему говорит хозяин, потому что хозяин знает, что открыто. */ surface, /* КОГО ВЕДЁМ — этого человека: та же личность, которой лист присутствует и которой подписаны его собственные правки. */ owner: HUMAN.id, brief: member satisfies AssistantBrief, }); crew.push(worker); } connect.disabled = true;});
overreach.addEventListener("click", () => { /* Лишнего просит ОДИН — первый: десять одинаковых отказов подряд не сказали бы человеку ничего сверх одного, а полоса показывает последний отказ КАЖДОГО участника, и десять строк закрыли бы половину листа. */ crew[0]?.postMessage({ kind: "ask", what: "overreach" });});
import.meta.hot?.dispose(() => { for (const worker of crew) worker.terminate(); core?.destroy(); link.close();});Книга примера: данные, с которыми лист открывается.
import { createMockTransport } from "@f1/contract";
/** * КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА. * * Транспорт контракта — единственное место, где лист разговаривает с ядром * (инвариант №1). В продукте здесь стоит `wasm` или мост хоста. * * ВАЖНО ДЛЯ ЭТОГО ПРИМЕРА: транспорт ОДИН, и правка помощника приходит в него тем * же путём, что правка человека. Именно поэтому написанное помощником видно в * ячейке — не потому, что мы нарисовали его текст поверх листа. */export const book = () => createMockTransport({ cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`), });Сторона хозяина страницы: что приложение отвечает листу и что от него получает.
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";import { DEFAULT_PRESENCE_ENDPOINT } from "@f1/presence";
/** * ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык. * * У встраивающего это уже есть своё: тему он знает из настроек, язык — из * профиля пользователя. Здесь и то и другое читается из адреса * (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было * дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком, * недоступна ни ссылке, ни автоматической проверке. * * ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не * лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает * СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном. * * НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром * страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от * арабского языка интерфейса не переворачивается. Это не пропуск, а решение * (`locale.ts`). */export type LinkKind = "broadcast" | "socket";
/** * ЧЕМ ЯВЛЯЕТСЯ ЭТА ВКЛАДКА — ЛИСТОМ ИЛИ ПУЛЬТОМ ПОМОЩНИКОВ. * * Появилось вместе с проводом. Пока помощник жил в воркере той же * страницы, роль была одна: страница держала и лист, и помощников. По сокету * ведущий живёт ГДЕ УГОДНО — в другой вкладке, в другом браузере, на другой * машине, — и это надо было сделать возможным, а не изобразить: `?role=drive` * открывает вкладку БЕЗ ЛИСТА, где есть только помощники и их приговоры. * * Так проверяется настоящая дорога: лист в одном окружении, ведущий в другом, * между ними ретранслятор и ничего больше. */export type PageRole = "sheet" | "drive";
export interface DriverHostAsks { readonly theme: "light" | "dark"; readonly locale: Locale; readonly link: LinkKind; readonly relay: string; readonly role: PageRole; /** * Какую поверхность ведут — ИМЯ ЛИСТА. * * У вкладки-листа оно спрашивается у самой книги; у пульта книги нет, и имя * ему говорит тот, кто его открыл, — ровно как в жизни хозяин говорит боту, * в какой лист писать. Догадываться внешней программе не по чему. */ readonly surface: string;}
export function hostAsks(): DriverHostAsks { const params = new URLSearchParams(location.search); const theme = params.get("theme") === "dark" ? "dark" : "light"; const locale = resolveLocale(params.get("locale") ?? undefined, "ru"); /* Неизвестное слово молча не превращается в сокет: тихая подстановка канала означала бы, что подпись на экране говорит не о том канале, который есть. */ const link: LinkKind = params.get("link") === "socket" ? "socket" : "broadcast";
document.documentElement.setAttribute("data-theme", theme); /* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */ setLocale(locale); applyLocaleAttributes(document.documentElement, locale);
return { theme, locale, link, relay: params.get("relay") ?? DEFAULT_PRESENCE_ENDPOINT, role: params.get("role") === "drive" ? "drive" : "sheet", surface: params.get("surface") ?? "Лист1", };}Оформление страницы вокруг листа.
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ. Собственное оформление лист приносит в теневые корни сам, но утилиты раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл замера у сетки без размеров). У потребителя вне репозитория это `@fastoffices/formula-kit` из его node_modules. */@import "@fastoffices/formula-kit"; /* см. примечание выше */html,body { height: 100%; margin: 0;}Из каких пакетов собран пример.
{ "name": "f1-example-driver", "private": true, "type": "module", "description": "Внешний участник ведёт лист: помощник живёт в СОСЕДНЕМ процессе, ходит по листу своим курсором, выделяет диапазон и пишет значение — а его отказ виден и ему, и человеку рядом.", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@f1/preset-sheets-core": "*", "@f1/presence": "*", "@f1/contract": "*", "@f1/i18n": "*" }, "devDependencies": { "vite": "^7.0.0" }}Файл примера.
/** * ПОМОЩНИК — ЧУЖАЯ ПРОГРАММА В СОСЕДНЕМ ПРОЦЕССЕ. * * Этот файл НЕ ЧАСТЬ ЛИСТА и не часть страницы. У него своя память, свой модуль * и ни одной общей переменной с той стороной: он достаёт до документа ровно * через канал и никак иначе. Ровно так же будет устроен участник, живущий на * другой машине, — сменится только реализация канала. * * ПОЧЕМУ ВОРКЕР, А НЕ ВТОРАЯ ВКЛАДКА. Вкладку нельзя открыть за человека, а * пример обязан работать и под обходом. Воркер — настоящий отдельный сеанс * исполнения: у него нет ни `document`, ни доступа к нашим объектам, и всё, что * он умеет, он умеет через `BroadcastChannel`. Никакого послабления это не даёт: * запрети ему канал — и он не сможет ничего. * * ГДЕ ОН ЖИВЁТ. В соседнем процессе, но канал ему * называет запустивший, и по `?link=socket` это НАСТОЯЩИЙ СОКЕТ: страница с * листом при этом может быть в другом окружении, в другом браузере или на * другой машине. Помощника это не касается ни строкой: он видит порт. * * ЧЕМ ОН ЯВЛЯЕТСЯ И ЧЕМ НЕ ЯВЛЯЕТСЯ. Он ведёт себя как ИИ-помощник: получает * просьбу, решает, что сделать, и объявляет намерения. Языковой модели внутри * него нет — и это НЕ упрощение примера, а граница продукта: кем бы ни был * участник по ту сторону канала, наш код его не зовёт и знать о нём не должен. * Поставьте сюда что угодно — договор не изменится. * * ЧТО ОН ОБЪЯВЛЯЕТ. Своё присутствие (три вести — как у человека) и намерения * (`write`, `command`). Ни одной операции контракта отсюда не уходит: помощник * ПРОСИТ, а решает композиционный корень. * * ЭКЗЕМПЛЯР — ОДИН НА ПОМОЩНИКА. Десять помощников — это десять * воркеров, а не один со списком: общий процесс на всех был бы одним сеансом, * притворяющимся десятью, и первое же «а что если один упадёт» это вскрыло бы. */import { PRESENCE_TIMING, broadcastLink, createDriveWire, createPresenceRoster, socketLink, type DriveVerdict, type PresenceLink,} from "@f1/presence";
export interface AssistantBrief { readonly id: string; readonly name: string; readonly col: number; readonly from: number;}
type ToAssistant = | { readonly kind: "start"; readonly space: string; /** * Каким каналом идти. Говорит запустивший — у чужой программы своего * представления о том, где искать комнату, нет. * * `socket` — это и есть весь смысл среза: помощник достаёт до листа, * открытого в ДРУГОМ окружении, и делает это через ретранслятор. Ни одной * строки самого помощника от смены канала не меняется: он видит порт. */ readonly link: "broadcast" | "socket"; readonly relay: string; readonly surface: string; /** * КОГО ОН ВЕДЁТ — хозяин листа, его личность. * * Говорит её тот, кто помощника запустил, ровно как и имя листа: у чужой * программы нет способа «догадаться», в чью книгу она пишет, а в комнате * ретранслятора хозяев столько, сколько окон открыло документ. */ readonly owner: string; readonly brief: AssistantBrief; } | { readonly kind: "ask"; readonly what: "overreach" };
export type FromAssistant = | { readonly kind: "ready"; readonly id: string } | { readonly kind: "verdict"; readonly verdict: DriveVerdict };
const COLUMNS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const ROWS = 22;
let link: PresenceLink | null = null;let roster: ReturnType<typeof createPresenceRoster> | null = null;let wire: ReturnType<typeof createDriveWire> | null = null;let surface = "";let me: AssistantBrief | null = null;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));const at = (col: number, row: number) => `${COLUMNS[col]}${row}`;
self.addEventListener("message", (event: MessageEvent<ToAssistant>) => { const message = event.data; if (message.kind === "start") void start(message); if (message.kind === "ask" && message.what === "overreach") overreach();});
async function start(order: Extract<ToAssistant, { kind: "start" }>) { if (roster) return; surface = order.surface; me = order.brief; link = order.link === "socket" ? socketLink({ space: order.space, url: order.relay }) : broadcastLink({ space: order.space }); roster = createPresenceRoster({ link, self: { id: me.id, name: me.name } }); wire = createDriveWire({ link, self: me.id, to: order.owner }); /* Приговоры уходят на страницу ТОЛЬКО ради журнала. Человеку их показывает сам лист полосой отказов, и показывает независимо от того, слушает ли кто-нибудь эту страницу. */ wire.hear((verdict) => self.postMessage({ kind: "verdict", verdict } satisfies FromAssistant)); roster.start(); /* Тик — половина дробления движения, как и у страницы: он досылает отложенное и подаёт признак жизни. Своего таймера у списка нет намеренно. */ setInterval(() => roster?.tick(), PRESENCE_TIMING.moveThrottleMs / 2); self.postMessage({ kind: "ready", id: me.id } satisfies FromAssistant); await work();}
/** * СЦЕНАРИЙ ОДНОГО ПОМОЩНИКА: смотрит свою колонку, выделяет в ней участок, * пишет итог — и продолжает смотреть. * * ОСМЫСЛЕННО, А НЕ СЛУЧАЙНЫМИ КООРДИНАТАМИ (требование к примеру: чтобы они реально * что-то делали по сценарию»). Дёрганье по случайным ячейкам выглядит живым * ровно одну секунду, после чего человек перестаёт понимать, что происходит, — * и перестаёт замечать, когда показ сломался. Здесь видно работу: каждый идёт * по своей колонке сверху вниз, останавливается на участке и вписывает итог. * * ПРАВКА ИДЁТ РАНО, А ХОДЬБА ПРОДОЛЖАЕТСЯ ПОСЛЕ: человеку не приходится ждать * результата, а замер цены кадра получает ХОДЯЩИХ соседей, а не дошедших. * Помощник, замерший в конце пути, стоит ровно столько же, сколько неподвижный. * * ПОЧЕМУ РАБОТА БЕСКОНЕЧНА, А ИТОГ ОБНОВЛЯЕТСЯ КАЖДЫЙ ПРОХОД. На десяти * помощниках со стороны казалось, что правки в ячейках не проходят. Замер * показал, чем это было на самом деле: правки ДОЕЗЖАЛИ (десять приговоров `ok` из десяти, и * все десять значений видны на снимке через 14 с), но каждый помощник писал * РОВНО ОДИН РАЗ, а через 18 секунд после запуска весь состав замирал навсегда * — восемь проходов и конец. Смотревший дольше двадцати секунд видел * неподвижную картину и ни одного изменения: работа, которой не видно, * неотличима от отсутствия работы, а это ровно то, чего пример обязан не * допускать. * * Поэтому проход не последний, а очередной, и каждый кончается ОБНОВЛЁННЫМ * итогом в ТОЙ ЖЕ ячейке. Книга мусором не наполняется — довод, ради которого * правка была однократной, сохранён буквально: ячейка одна на помощника, * меняется в ней значение. Замер после починки: 169 приговоров за 40 с вместо * десяти, движение курсоров до конца наблюдения вместо 18-й секунды. */async function work() { if (!roster || !wire || !me) return; const col = me.col; const top = me.from + 6; for (let round = 0; ; round += 1) { await walk(col, me.from, ROWS); /* ВЫДЕЛЯЕТ УЧАСТОК И ДЕРЖИТ ЕГО. Охват — такой же ВЗГЛЯД, как и курсор: он ничего не меняет в книге и потому не намерение, а обычная весть присутствия. ДЕРЖИТ — потому что мелькнувшая на четверть секунды рамка не читается человеком и недоказуема обходом: и то и другое требует, чтобы состояние ПОБЫЛО. */ roster.focus({ surface, at: at(col, top), span: `${at(col, top)}:${at(col + 1, top + 4)}` }); await wait(1200); /* И ПИШЕТ ИТОГ — вот это уже намерение: оно меняет книгу. КАЖДЫЙ ПРОХОД и в ТУ ЖЕ ячейку: номер прохода в значении и есть то, по чему человек видит, что работа идёт, а не кончилась (см. шапку функции). */ wire.declare({ kind: "write", at: at(col, top), input: `итог ${me.name} ${round + 1}` }); }}
async function walk(col: number, from: number, steps: number) { for (let step = 0; step < steps; step += 1) { roster?.focus({ surface, at: at(col, from + (step % ROWS)) }); await wait(PRESENCE_TIMING.moveThrottleMs); }}
function overreach() { /* Команда настоящая и из нашего реестра — второго словаря для внешних нет. Отказ будет не «такой команды не бывает», а «команда не выдана»: разница принципиальная, и её видно в приговоре. */ wire?.declare({ kind: "command", command: "workbook.protect" });}Файл примера.
/** * СКОЛЬКО ПОМОЩНИКОВ И КТО ОНИ — ДАННЫЕ, А НЕ КОНСТАНТА В РАЗМЕТКЕ. * * Помощников может быть и три, и десять, и каждый должен делать настоящую * работу по сценарию, а не изображать её. * * ЧИСЛО — ПАРАМЕТР АДРЕСА (`?assistants=10`), и это принципиально: * если на десяти что-то ломается — ярлыки налезают, дорожает кадр, путаются * цвета, — это находка, которую называют числом, а не прячут уменьшением числа * участников. Обход поэтому ходит на те же адреса, что человек. * * ОБЛАСТЬ У КАЖДОГО СВОЯ, И ЭТО НЕ УКРАШЕНИЕ. Десять курсоров, гуляющих по * одним и тем же ячейкам, — это каша, по которой нельзя сказать, работает * показ или нет. Своя колонка у каждого делает картину ЧИТАЕМОЙ: человек видит, * что происходит, а спека может проверить, что участник ходит именно там, где * ему положено. * * ЛИЧНОСТЬ ЗДЕСЬ — ОТ ХОЗЯИНА СТРАНИЦЫ, как и везде: в продукте эти имена * приедут от учётных записей. Цвет не назначается — его считает `colorOf` по * идентификатору, тем же кругом, которым помечены курсор и рамка. */
const NAMES = [ "Ада", "Борис", "Вера", "Глеб", "Дина", "Егор", "Жанна", "Захар", "Ирина", "Кирилл",] as const;
export const CREW_LIMIT = NAMES.length;
export interface CrewMember { readonly id: string; readonly name: string; /** * Столбец, в котором он работает (с нуля). * * Через один: соседние колонки поставили бы ярлыки имён вплотную, а * читаемость картины — половина заказа. Десятый помощник занимает столбец * `1 + 9*2 = 19` (T), то есть все десять умещаются в видимой части листа. */ readonly col: number; /** * С какой строки он начинает обход. * * РАЗНЫЕ ФАЗЫ У РАЗНЫХ ПОМОЩНИКОВ — тоже про читаемость: стартовав вместе, * десять курсоров шли бы одной шеренгой, и ярлыки имён встали бы в один ряд * поперёк листа. Разведённые фазы дают картину, в которой видно каждого. */ readonly from: number;}
/** * Кто выйдет на лист при заданном числе помощников. * * @param count сколько их просят; лишнее отсекается пределом показа * @returns состав: у каждого своё имя, свой столбец и своя фаза обхода * * @example * crewOf(3).map((member) => member.name); // ["Ада", "Борис", "Вера"] */export function crewOf(count: number): readonly CrewMember[] { const size = Math.max(1, Math.min(CREW_LIMIT, Math.trunc(count))); return NAMES.slice(0, size).map((name, index) => ({ id: `assistant-${index + 1}`, name, col: 1 + index * 2, from: 1 + ((index * 3) % 12), }));}
/** * Сколько помощников просит адрес страницы. * * @param search строка запроса (`?assistants=10`) * @returns число в пределах показа; по умолчанию четыре * * @example * crewSize("?assistants=10"); // 10 * crewSize("?assistants=99"); // 10 — предел показа, а не ошибка * crewSize(""); // 4 */export function crewSize(search: string): number { const asked = Number(new URLSearchParams(search).get("assistants")); /* ЧЕТЫРЕ ПО УМОЛЧАНИЮ, а не десять: страница должна открываться читаемой, а десять — это состояние, которое ПРОСЯТ, чтобы посмотреть на предел. */ if (!Number.isFinite(asked) || asked <= 0) return 4; return Math.min(CREW_LIMIT, Math.trunc(asked));}- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.