505 lines
21 KiB
TypeScript
505 lines
21 KiB
TypeScript
/// SPDX-License-Identifier: GPL-3.0-or-later
|
|
/// SPDX-FileCopyrightText: Copyright © 2022 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
|
|
|
|
import { Dataspace, IdentitySet, Ref, Bytes, Observe, Turn, Reader, genericEmbeddedTypeDecode, Schemas, GenericEmbedded, Dictionary, fromJS, stringify } from "@syndicate-lang/core";
|
|
import { QuasiValue as Q } from "@syndicate-lang/core";
|
|
const Trace = Schemas.trace;
|
|
import * as P from './pict';
|
|
|
|
import { boot as bootHtml, UIFragment } from "@syndicate-lang/html";
|
|
|
|
assertion type URLContent(url: string, content: Bytes | false);
|
|
|
|
const SWIMLANE_WIDTH = 100;
|
|
const LIFELINE_WIDTH = 5;
|
|
const ACTIVATION_WIDTH = LIFELINE_WIDTH * 2;
|
|
const VERTICAL_GAP = 20;
|
|
const POST_TURN_GAP = VERTICAL_GAP * 3;
|
|
const PLAYGROUND_PADDING = 10;
|
|
const TAIL_LENGTH = 60;
|
|
const TERMINATOR_SIZE = new P.Point(LIFELINE_WIDTH * 3, LIFELINE_WIDTH);
|
|
|
|
export function main() {
|
|
Dataspace.boot(ds => {
|
|
P.init();
|
|
bootHtml(ds);
|
|
bootFetcher(ds);
|
|
bootRenderer(ds);
|
|
});
|
|
}
|
|
|
|
function bootFetcher(ds: Ref) {
|
|
spawn named 'fetcher' {
|
|
at ds {
|
|
during Observe({
|
|
"pattern": :pattern URLContent(\Q.lit($url: string), \_)
|
|
}) => spawn named ['fetch', url] {
|
|
const facet = Turn.activeFacet;
|
|
facet.preventInertCheck();
|
|
fetch(url)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
console.log('unsuccessful request', url, response);
|
|
facet.turn(() => {
|
|
assert URLContent(url, false);
|
|
});
|
|
} else {
|
|
response.arrayBuffer().then(bs => facet.turn(() => {
|
|
assert URLContent(url, Bytes.from(bs));
|
|
}));
|
|
}
|
|
})
|
|
.catch(error => facet.turn(() => {
|
|
console.error('failed request', url, error);
|
|
assert URLContent(url, false);
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function bootRenderer(ds: Ref) {
|
|
spawn named 'renderer' {
|
|
const r = new Renderer(ds);
|
|
at ds {
|
|
const traceUrl = 'd2';
|
|
during URLContent(traceUrl, false) => {
|
|
alert("Couldn't fetch trace file: " + JSON.stringify(traceUrl));
|
|
}
|
|
during URLContent(traceUrl, $bs: Bytes) => {
|
|
const vs = new Reader(bs.fromUtf8(), {
|
|
embeddedDecode: genericEmbeddedTypeDecode,
|
|
}).readToEnd();
|
|
const f = Turn.activeFacet;
|
|
// let count = 0;
|
|
function loop(n: number) {
|
|
// if (count >= 100) return;
|
|
if (n > 0) {
|
|
const v = vs.shift();
|
|
if (v !== void 0) {
|
|
r.addTraceEntry(Trace.asTraceEntry(v));
|
|
// count++;
|
|
loop(n - 1);
|
|
}
|
|
} else {
|
|
setTimeout(() => f.turn(() => loop(1)), 10);
|
|
// r.container.scrollLeft =
|
|
// (r.playground.width.baseVal.value - r.container.getBoundingClientRect().width)
|
|
// / 2;
|
|
}
|
|
}
|
|
loop(0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function indented(x: any): string {
|
|
return stringify(x, { indent: 2});
|
|
}
|
|
|
|
function prettyFacetPath(p: Schemas.trace.FacetId<GenericEmbedded>[]): string {
|
|
return p.map(i => stringify(i)).join(':');
|
|
}
|
|
|
|
function reindent(s: string, amount: number, firstAmount: number = amount): string {
|
|
const indent = ' '.repeat(amount);
|
|
return s.split('\n').map((line, i) => {
|
|
if (i === 0) {
|
|
return ' '.repeat(firstAmount) + line;
|
|
} else {
|
|
return indent + line;
|
|
}
|
|
}).join('\n');
|
|
}
|
|
|
|
function prettyName(n: Schemas.trace.Name<GenericEmbedded>): string {
|
|
switch (n._variant) {
|
|
case "named":
|
|
return indented(n.name);
|
|
case "anonymous":
|
|
return '(anonymous)';
|
|
}
|
|
}
|
|
|
|
function prettyTarget(e: Schemas.trace.Target<GenericEmbedded>): string {
|
|
const oid = typeof e.oid === 'number'
|
|
? ('0000000000000000' + e.oid.toString(16)).slice(-16)
|
|
: stringify(e.oid);
|
|
return `${stringify(e.actor)}/${stringify(e.facet)}:${oid}`;
|
|
}
|
|
|
|
function prettyAssertionDescription(a: Schemas.trace.AssertionDescription<GenericEmbedded>): string {
|
|
switch (a._variant) {
|
|
case "value": return indented(a.value);
|
|
case "opaque": return `⌜${stringify(a.description)}⌝`;
|
|
}
|
|
}
|
|
|
|
class Swimlane {
|
|
readonly actorId: Schemas.trace.ActorId<GenericEmbedded>;
|
|
actorName: Schemas.trace.Name<GenericEmbedded> = Trace.Name.anonymous();
|
|
readonly renderer: Renderer;
|
|
readonly laneNumber: number;
|
|
readonly startY: number;
|
|
readonly entries: Array<P.BoundedPict<any>> = [];
|
|
readonly lifeline = P.rect([LIFELINE_WIDTH, 0], {"class": "lifeline"});
|
|
tail: P.BoundedPict<SVGRectElement> | null =
|
|
P.rect([LIFELINE_WIDTH, TAIL_LENGTH], {"class": "lifeline-tail"});
|
|
|
|
constructor (actorId: Schemas.trace.ActorId<GenericEmbedded>, laneNumber: number, renderer: Renderer) {
|
|
this.actorId = actorId;
|
|
this.renderer = renderer;
|
|
this.laneNumber = laneNumber;
|
|
this.startY = renderer.ypos;
|
|
|
|
this.lifeline.node.appendChild(P.svg.tag(SVGTitleElement)`<title></title>`);
|
|
this.lifelineTooltipAppend(`Actor ID: ${indented(this.actorId)}`);
|
|
this.lifeline.updateBounds(b => b.withTopMid([this.midX, this.startY]));
|
|
this.updateLifelineHeight();
|
|
|
|
this.renderer.underlay.appendChild(this.lifeline.node);
|
|
this.renderer.underlay.appendChild(this.tail!.node);
|
|
}
|
|
|
|
updateLifelineHeight() {
|
|
this.lifeline.updateBounds(b => b.withHeight(this.renderer.ypos - b.top));
|
|
this.tail?.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
|
}
|
|
|
|
lifelineTooltipAppend(s: string) {
|
|
const title = this.lifeline.node.querySelector('title')!;
|
|
title.appendChild(document.createTextNode(s));
|
|
}
|
|
|
|
get midX(): number {
|
|
return this.laneNumber * SWIMLANE_WIDTH;
|
|
}
|
|
|
|
get label(): string {
|
|
return `${prettyName(this.actorName)}[${stringify(this.actorId)}]`;
|
|
}
|
|
|
|
addEntry(klass: string | null, entry: P.BoundedPict<any>): P.BoundedPict<any> {
|
|
this.entries.push(entry);
|
|
|
|
if (klass !== null) {
|
|
const pin = P.circle(2, {"class": "pin"});
|
|
pin.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
|
this.renderer.advance(pin.bounds.height);
|
|
this.renderer.overlay.appendChild(pin.node);
|
|
}
|
|
|
|
const n = klass === null ? entry : P.placard(entry, {"class": klass});
|
|
n.updateBounds(b => b.withTopMid([this.midX, this.renderer.ypos]));
|
|
this.renderer.overlay.appendChild(n.node);
|
|
this.renderer.advance(n.bounds.height);
|
|
this.updateLifelineHeight();
|
|
|
|
this.renderer.adjustViewport();
|
|
|
|
return n;
|
|
}
|
|
|
|
finish() {
|
|
this.renderer.underlay.removeChild(this.tail!.node);
|
|
this.tail = null;
|
|
this.renderer.releaseSwimlane(this.laneNumber);
|
|
}
|
|
}
|
|
|
|
class Renderer {
|
|
ds: Ref;
|
|
readonly container: HTMLDivElement = document.getElementById('container')! as any;
|
|
readonly playground: SVGSVGElement = document.getElementById('playground')! as any;
|
|
readonly underlay: SVGGElement = this.playground.getElementById('underlay')! as any;
|
|
readonly overlay: SVGGElement = this.playground.getElementById('overlay')! as any;
|
|
readonly swimlanes = new Dictionary<GenericEmbedded, Swimlane>();
|
|
readonly busyLanes = new IdentitySet<number>();
|
|
readonly turnMap = new Dictionary<GenericEmbedded, { point: P.Point, swimlane: Swimlane }>();
|
|
readonly handleMap =
|
|
new Dictionary<GenericEmbedded, Schemas.trace.AssertionDescription<GenericEmbedded>>();
|
|
ypos = 0;
|
|
|
|
constructor (ds: Ref) {
|
|
this.ds = ds;
|
|
|
|
this.adjustViewport();
|
|
at this.ds {
|
|
on asserted UIFragment($_fragmentId, $_newSelector, $_newHtml, $_newOrderBy) => {
|
|
this.adjustViewport();
|
|
}
|
|
}
|
|
}
|
|
|
|
adjustViewport() {
|
|
const bbox = this.playground.getBBox();
|
|
this.playground.viewBox.baseVal.x = bbox.x - PLAYGROUND_PADDING;
|
|
this.playground.viewBox.baseVal.y = bbox.y - PLAYGROUND_PADDING;
|
|
this.playground.viewBox.baseVal.width = bbox.width + PLAYGROUND_PADDING * 2;
|
|
this.playground.viewBox.baseVal.height = bbox.height + PLAYGROUND_PADDING * 2;
|
|
this.playground.width.baseVal.value = bbox.width + PLAYGROUND_PADDING * 2;
|
|
this.playground.height.baseVal.value = bbox.height + PLAYGROUND_PADDING * 2;
|
|
}
|
|
|
|
allocateSwimlane(): number {
|
|
let i = 0;
|
|
while (this.busyLanes.has(i)) i++;
|
|
this.busyLanes.add(i);
|
|
return i;
|
|
}
|
|
|
|
releaseSwimlane(n: number) {
|
|
this.busyLanes.delete(n);
|
|
}
|
|
|
|
swimlaneFor(actorId: Schemas.trace.ActorId<GenericEmbedded>): Swimlane {
|
|
let k = fromJS(actorId);
|
|
let s = this.swimlanes.get(k);
|
|
if (s === void 0) {
|
|
this.swimlanes.set(k, s = new Swimlane(actorId, this.allocateSwimlane(), this));
|
|
}
|
|
return s;
|
|
}
|
|
|
|
advance(delta: number) {
|
|
this.ypos += delta;
|
|
}
|
|
|
|
connect(ps: P.Point, pe: P.Point) {
|
|
if (pe.x == ps.x) {
|
|
const pullMagnitude = pe.sub(ps).scale(0.1).length;
|
|
const pullAngle = Math.PI;
|
|
const pull = P.Point.rTheta(pullMagnitude + 30, pullAngle);
|
|
// TODO: compute marker size from the actual definition
|
|
const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle);
|
|
this.overlay.appendChild(
|
|
P.cubicBezier(ps,
|
|
ps.add(pull),
|
|
pe.add(pull),
|
|
pe.add(arrowheadSpace),
|
|
{"class": "arrow"}).node);
|
|
} else {
|
|
const pullMagnitude = pe.sub(ps).scale(0.1).length;
|
|
const pullAngle = pe.x > ps.x ? Math.PI/4 : 3*Math.PI/4;
|
|
const pull = P.Point.rTheta(pullMagnitude, pullAngle);
|
|
// TODO: compute marker size from the actual definition
|
|
const arrowheadSpace = P.Point.rTheta(15 - 3.75, pullAngle);
|
|
const pe1 = pe.sub(arrowheadSpace);
|
|
this.overlay.appendChild(
|
|
P.cubicBezier(ps,
|
|
ps.add(pull),
|
|
pe1.sub(pull),
|
|
pe1,
|
|
{"class": "arrow"}).node);
|
|
}
|
|
}
|
|
|
|
rewindTo<R>(pos: number, f: () => R): R {
|
|
const saved = this.ypos;
|
|
this.ypos = pos;
|
|
try {
|
|
return f();
|
|
} finally {
|
|
this.ypos = Math.max(saved, this.ypos);
|
|
}
|
|
}
|
|
|
|
prettyTurnEvent(t: Schemas.trace.TurnEvent<GenericEmbedded>): string {
|
|
switch (t._variant) {
|
|
case "assert":
|
|
this.handleMap.set(t.handle, t.assertion);
|
|
return `assert H${t.handle} ↔ ${prettyAssertionDescription(t.assertion)}`;
|
|
case "retract": {
|
|
const assertion = this.handleMap.get(t.handle)
|
|
?? Trace.AssertionDescription.opaque(Symbol.for('???'));
|
|
return `retract H${t.handle} ↔ ${prettyAssertionDescription(assertion)}`;
|
|
}
|
|
case "message":
|
|
return `message ${prettyAssertionDescription(t.body)}`;
|
|
case "sync":
|
|
return `sync ${prettyTarget(t.peer)}`;
|
|
case "breakLink":
|
|
return `link broken`;
|
|
}
|
|
}
|
|
|
|
addTraceEntry(entry: Schemas.trace.TraceEntry<GenericEmbedded>) {
|
|
switch (entry.item._variant) {
|
|
case "start": {
|
|
this.advance(LIFELINE_WIDTH);
|
|
const swimlane = this.swimlaneFor(entry.actor);
|
|
swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"}));
|
|
this.advance(LIFELINE_WIDTH);
|
|
swimlane.actorName = entry.item.actorName;
|
|
swimlane.addEntry("start", P.text(swimlane.label));
|
|
swimlane.lifelineTooltipAppend(`\nActor name: ${prettyName(entry.item.actorName)}`);
|
|
break;
|
|
}
|
|
case "turn": {
|
|
const turn = entry.item.value;
|
|
const swimlane = this.swimlaneFor(entry.actor);
|
|
const activation = P.rect([ACTIVATION_WIDTH, 0], {"class": "activation"});
|
|
this.advance(LIFELINE_WIDTH);
|
|
activation.updateBounds(b => b.withTopMid([swimlane.midX, this.ypos]));
|
|
|
|
let causingTurnId: Schemas.trace.TurnId<GenericEmbedded> | null = null;
|
|
switch (turn.cause._variant) {
|
|
case "turn":
|
|
causingTurnId = turn.cause.id;
|
|
break;
|
|
case "delay":
|
|
causingTurnId = turn.cause.causingTurn;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
const cause = (causingTurnId !== null)
|
|
? this.turnMap.get(fromJS(causingTurnId))!
|
|
: void 0;
|
|
if (cause !== void 0) {
|
|
this.connect(cause.point, new P.Point(swimlane.midX, this.ypos));
|
|
}
|
|
|
|
let triggerEvent: Schemas.trace.TargetedTurnEvent<GenericEmbedded> | null = null;
|
|
|
|
this.advance(LIFELINE_WIDTH);
|
|
swimlane.addEntry("turn-start", P.text(
|
|
indented(turn.id) + ': ' + indented(turn.cause)));
|
|
|
|
turn.actions.forEach(a => {
|
|
let entryClass: string;
|
|
let label: string;
|
|
let tooltip: string = `Turn ${stringify(turn.id)} in ${swimlane.label}`;
|
|
if (cause !== void 0) {
|
|
tooltip += `\n caused by ${stringify(causingTurnId!)} in ${cause.swimlane.label}`;
|
|
}
|
|
if (a._variant !== "dequeue" && a._variant !== "dequeueInternal" && triggerEvent !== null) {
|
|
tooltip += `\n while processing ${prettyTarget(triggerEvent.target)} ← ${reindent(this.prettyTurnEvent(triggerEvent.detail), 4, 0)}`;
|
|
}
|
|
let isInternal = false;
|
|
switch (a._variant) {
|
|
case "dequeueInternal":
|
|
isInternal = true;
|
|
/* FALL THROUGH */
|
|
case "dequeue":
|
|
entryClass = "event";
|
|
label = `received for ${prettyTarget(a.event.target)}:\n${reindent(this.prettyTurnEvent(a.event.detail), 2)}`;
|
|
triggerEvent = a.event;
|
|
break;
|
|
case "enqueueInternal":
|
|
isInternal = true;
|
|
/* FALL THROUGH */
|
|
case "enqueue":
|
|
entryClass = "action";
|
|
label = `sending to ${prettyTarget(a.event.target)}:\n${reindent(this.prettyTurnEvent(a.event.detail), 2)}`;
|
|
break;
|
|
case "link": {
|
|
const other = this.swimlaneFor(a.childActor);
|
|
entryClass = "action";
|
|
label = `linked to ${other.label}`;
|
|
break;
|
|
}
|
|
case "spawn": {
|
|
const other = this.swimlaneFor(a.id);
|
|
entryClass = "action";
|
|
label = `spawn ${other.label}`;
|
|
break;
|
|
}
|
|
case "facetStart":
|
|
entryClass = "internal";
|
|
label = `start ${prettyFacetPath(a.path)}`;
|
|
break;
|
|
case "facetStop":
|
|
entryClass = "internal";
|
|
label = `stop ${prettyFacetPath(a.path)}`;
|
|
switch (a.reason._variant) {
|
|
case "explicitAction": label += " (explicit action)"; break;
|
|
case "parentStopping": label += " (parent facet stopping)"; break;
|
|
case "inert": label += " (facet is inert)"; break;
|
|
case "actorStopping": label += " (actor terminating)"; break;
|
|
default:
|
|
label += '\n' + indented(a.reason);
|
|
break;
|
|
}
|
|
break;
|
|
case "linkedTaskStart":
|
|
entryClass = "start";
|
|
label = indented(a);
|
|
break;
|
|
}
|
|
this.advance(LIFELINE_WIDTH);
|
|
const fPos = this.ypos;
|
|
const e = swimlane.addEntry(entryClass, P.text(label));
|
|
if (tooltip !== null) {
|
|
e.node.appendChild(P.svg.tag(SVGTitleElement)`<title>${tooltip}</title>`);
|
|
}
|
|
switch (a._variant) {
|
|
case "link": {
|
|
const other = this.swimlaneFor(a.childActor);
|
|
const f = this.rewindTo(fPos, () =>
|
|
other.addEntry(entryClass, P.text(`linked to ${swimlane.label}`)));
|
|
// TODO: compute marker size from the actual definition
|
|
const arrowheadSpace = new P.Point(
|
|
(15 - 3.75) * (other.midX > swimlane.midX ? -1 : 1),
|
|
0);
|
|
if (e.bounds.height < f.bounds.height) {
|
|
e.updateBounds(b => b.withMidY(f.bounds.midY));
|
|
} else {
|
|
f.updateBounds(b => b.withMidY(e.bounds.midY));
|
|
}
|
|
const ps = other.midX > swimlane.midX
|
|
? e.bounds.midRight.sub(arrowheadSpace)
|
|
: e.bounds.midLeft.sub(arrowheadSpace);
|
|
const pe = other.midX > swimlane.midX
|
|
? f.bounds.midLeft.add(arrowheadSpace)
|
|
: f.bounds.midRight.sub(arrowheadSpace);
|
|
this.overlay.appendChild(P.line(ps, pe, {"class": "doublearrow"}).node);
|
|
break;
|
|
}
|
|
case "spawn": {
|
|
if (!a.link) {
|
|
const other = this.swimlaneFor(a.id);
|
|
this.connect(new P.Point(swimlane.midX, this.ypos),
|
|
new P.Point(other.midX, this.ypos));
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
this.advance(LIFELINE_WIDTH);
|
|
this.turnMap.set(fromJS(turn.id), {
|
|
point: new P.Point(swimlane.midX, this.ypos),
|
|
swimlane,
|
|
});
|
|
activation.updateBounds(b => b.withHeight(this.ypos - b.top));
|
|
this.underlay.appendChild(activation.node);
|
|
this.advance(POST_TURN_GAP - VERTICAL_GAP);
|
|
swimlane.updateLifelineHeight();
|
|
break;
|
|
}
|
|
case "stop": {
|
|
this.advance(VERTICAL_GAP);
|
|
const swimlane = this.swimlaneFor(entry.actor);
|
|
switch (entry.item.status._variant) {
|
|
case "ok":
|
|
swimlane.addEntry("stop-ok", P.text("✓"));
|
|
break;
|
|
case "Error":
|
|
swimlane.addEntry("stop-error", P.text(
|
|
"ERROR: " + indented(entry.item.status.value),
|
|
{"class": "stop-error"}));
|
|
break;
|
|
}
|
|
this.advance(LIFELINE_WIDTH);
|
|
swimlane.addEntry(null, P.rect(TERMINATOR_SIZE, {"class": "terminator"}));
|
|
this.advance(LIFELINE_WIDTH);
|
|
swimlane.finish();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|