@infinitedusky/indusk-mcp 1.64.0 → 1.65.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/.next/BUILD_ID +1 -1
- package/admin/.next/build-manifest.json +3 -3
- package/admin/.next/fallback-build-manifest.json +3 -3
- package/admin/.next/server/app/_global-error.html +1 -1
- package/admin/.next/server/app/_global-error.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/admin/.next/server/middleware-build-manifest.js +3 -3
- package/admin/.next/server/pages/500.html +1 -1
- package/dist/bin/cli.js +8 -0
- package/dist/bin/commands/demo.d.ts +17 -0
- package/dist/bin/commands/demo.js +87 -0
- package/dist/bin/commands/telemetry.js +5 -0
- package/dist/lib/promises/citations.d.ts +6 -1
- package/dist/lib/promises/citations.js +12 -3
- package/dist/lib/telemetry/daemon.d.ts +6 -3
- package/dist/lib/telemetry/daemon.js +23 -55
- package/dist/lib/telemetry/stop.d.ts +33 -0
- package/dist/lib/telemetry/stop.js +45 -0
- package/dist/lib/version-state.d.ts +1 -1
- package/dist/lib/version-state.js +1 -0
- package/examples/seat-holds/.dockerignore +3 -0
- package/examples/seat-holds/.indusk/config.json +5 -0
- package/examples/seat-holds/.indusk/planning/archive/seat-holds/brief.md +36 -0
- package/examples/seat-holds/.indusk/promises/a-held-seat-is-released-in-time.md +18 -0
- package/examples/seat-holds/.indusk/promises/the-deployed-demo-page-answers.md +19 -0
- package/examples/seat-holds/Dockerfile +14 -0
- package/examples/seat-holds/README.md +87 -0
- package/examples/seat-holds/fly.toml +32 -0
- package/examples/seat-holds/package.json +25 -0
- package/examples/seat-holds/public/index.html +85 -0
- package/examples/seat-holds/scripts/smoke-deployed.mjs +29 -0
- package/examples/seat-holds/src/seats.test.ts +45 -0
- package/examples/seat-holds/src/seats.ts +90 -0
- package/examples/seat-holds/src/server.ts +98 -0
- package/examples/seat-holds/src/telemetry.ts +56 -0
- package/examples/seat-holds/vitest.config.ts +7 -0
- package/package.json +2 -1
- /package/admin/.next/static/{Fup9Ab45THH4baThbLtlT → LYIODRL5e_tRCw1EFdnEf}/_buildManifest.js +0 -0
- /package/admin/.next/static/{Fup9Ab45THH4baThbLtlT → LYIODRL5e_tRCw1EFdnEf}/_clientMiddlewareManifest.js +0 -0
- /package/admin/.next/static/{Fup9Ab45THH4baThbLtlT → LYIODRL5e_tRCw1EFdnEf}/_ssgManifest.js +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { createServer } from "node:net";
|
|
@@ -24,11 +24,13 @@ import { join } from "node:path";
|
|
|
24
24
|
* immediately after `daemonStart` resolves — the PID/meta files are the
|
|
25
25
|
* durable handoff to subsequent `telemetry status`/`telemetry stop` invocations.
|
|
26
26
|
*
|
|
27
|
-
* PID-reuse hardening: `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* PID-reuse hardening: `status` composes `isAlive(pid)` with
|
|
28
|
+
* `isPortListening(port)` (`verifyIdentity`) so a recycled PID is not reported
|
|
29
|
+
* running. `stop` judges ownership by the process's command line instead
|
|
30
|
+
* (`stop.ts`): a slow port must never make it skip its own processes.
|
|
30
31
|
*/
|
|
31
|
-
import { cleanupFiles, daemonStatus, induskHome, isAlive, isPortListening, metaFilePath, pidFilePath,
|
|
32
|
+
import { cleanupFiles, daemonStatus, induskHome, isAlive, isPortListening, metaFilePath, pidFilePath, } from "./status.js";
|
|
33
|
+
import { stopDaemon } from "./stop.js";
|
|
32
34
|
export { daemonMetaPath, daemonStatus, isPortListening, liveOtlpEndpointSync, } from "./status.js";
|
|
33
35
|
function logFilePath() {
|
|
34
36
|
return join(induskHome(), "telemetry.log");
|
|
@@ -419,63 +421,29 @@ export async function daemonStop() {
|
|
|
419
421
|
cleanupFiles();
|
|
420
422
|
return { stopped: false };
|
|
421
423
|
}
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
424
|
+
// Ownership is the command line, never the port (telemetry-stop-stops-what-it-started):
|
|
425
|
+
// a slow port once made stop signal nothing, delete the record and report success.
|
|
426
|
+
const result = await stopDaemon(meta, induskHome(), {
|
|
427
|
+
alive: isAlive,
|
|
428
|
+
command: processCommand,
|
|
429
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
430
|
+
sleep,
|
|
431
|
+
});
|
|
432
|
+
if (result.clearRecord)
|
|
427
433
|
cleanupFiles();
|
|
428
|
-
return {
|
|
429
|
-
stopped: true,
|
|
430
|
-
signaledJaegerPid: meta.jaegerPid,
|
|
431
|
-
signaledOtelcolPid: meta.otelcolPid,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
// Signal both; poll for exit up to 3s; SIGKILL fallback
|
|
435
|
-
const targets = [];
|
|
436
|
-
if (jaegerOk)
|
|
437
|
-
targets.push(meta.jaegerPid);
|
|
438
|
-
if (otelcolOk)
|
|
439
|
-
targets.push(meta.otelcolPid);
|
|
440
|
-
for (const pid of targets) {
|
|
441
|
-
try {
|
|
442
|
-
process.kill(pid, "SIGTERM");
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
// already gone
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
for (let i = 0; i < 30; i++) {
|
|
449
|
-
await sleep(100);
|
|
450
|
-
if (targets.every((pid) => !isAlive(pid))) {
|
|
451
|
-
cleanupFiles();
|
|
452
|
-
return {
|
|
453
|
-
stopped: true,
|
|
454
|
-
signaledJaegerPid: meta.jaegerPid,
|
|
455
|
-
signaledOtelcolPid: meta.otelcolPid,
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
let usedSigkill = false;
|
|
460
|
-
for (const pid of targets) {
|
|
461
|
-
if (isAlive(pid)) {
|
|
462
|
-
try {
|
|
463
|
-
process.kill(pid, "SIGKILL");
|
|
464
|
-
usedSigkill = true;
|
|
465
|
-
}
|
|
466
|
-
catch {
|
|
467
|
-
// best-effort
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
cleanupFiles();
|
|
472
434
|
return {
|
|
473
435
|
stopped: true,
|
|
474
436
|
signaledJaegerPid: meta.jaegerPid,
|
|
475
437
|
signaledOtelcolPid: meta.otelcolPid,
|
|
476
|
-
|
|
438
|
+
stillRunning: result.stillRunning,
|
|
477
439
|
};
|
|
478
440
|
}
|
|
441
|
+
/** A process's command line, or null when it is gone or cannot be read. */
|
|
442
|
+
function processCommand(pid) {
|
|
443
|
+
const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf-8" });
|
|
444
|
+
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
445
|
+
return out === "" ? null : out;
|
|
446
|
+
}
|
|
479
447
|
export async function daemonRestart(opts = {}) {
|
|
480
448
|
// Restart means "same daemon, fresh processes" — inherit the previously
|
|
481
449
|
// bound ports before stopping so the new spawn rebinds the same addresses
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DaemonMeta } from "./status.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stopping the telemetry daemon (telemetry-stop-stops-what-it-started).
|
|
4
|
+
*
|
|
5
|
+
* A recorded process is this daemon's when it is alive and its command line
|
|
6
|
+
* runs the recorded binary with this home's own config file. Its port is not
|
|
7
|
+
* asked: under load a port can be slow, and judging by it once made stop
|
|
8
|
+
* signal nothing, delete the record and report the daemon stopped (the 1.65.0
|
|
9
|
+
* release left nine processes that way). A PID now held by another program,
|
|
10
|
+
* or by the same binary from another home, is never signalled. Stop reports
|
|
11
|
+
* stopped only when none of its own is left, and keeps the record otherwise.
|
|
12
|
+
*
|
|
13
|
+
* promise: telemetry-stop-stops-what-it-started
|
|
14
|
+
*/
|
|
15
|
+
/** What stopping reads and does, given as inputs so the decision is a unit test. */
|
|
16
|
+
export interface StopDeps {
|
|
17
|
+
alive(pid: number): boolean;
|
|
18
|
+
command(pid: number): string | null;
|
|
19
|
+
kill(pid: number, signal: NodeJS.Signals): void;
|
|
20
|
+
sleep(ms: number): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface StopResult {
|
|
23
|
+
/** None of the daemon's own processes is left running. */
|
|
24
|
+
stopped: boolean;
|
|
25
|
+
/** The record may be deleted: nothing of its own still runs. */
|
|
26
|
+
clearRecord: boolean;
|
|
27
|
+
signaled: number[];
|
|
28
|
+
strangers: number[];
|
|
29
|
+
stillRunning: number[];
|
|
30
|
+
}
|
|
31
|
+
/** Whether `pid` is this daemon's process: alive, and running `binary` with `config`. */
|
|
32
|
+
export declare function isOwnProcess(pid: number, binary: string, config: string, deps: StopDeps): boolean;
|
|
33
|
+
export declare function stopDaemon(meta: DaemonMeta, home: string, deps: StopDeps): Promise<StopResult>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
const GRACE_MS = 3000;
|
|
3
|
+
const POLL_MS = 100;
|
|
4
|
+
/** Whether `pid` is this daemon's process: alive, and running `binary` with `config`. */
|
|
5
|
+
export function isOwnProcess(pid, binary, config, deps) {
|
|
6
|
+
if (!deps.alive(pid))
|
|
7
|
+
return false;
|
|
8
|
+
const cmd = deps.command(pid);
|
|
9
|
+
return cmd !== null && cmd.includes(binary) && cmd.includes(config);
|
|
10
|
+
}
|
|
11
|
+
export async function stopDaemon(meta, home, deps) {
|
|
12
|
+
const recorded = [
|
|
13
|
+
{ pid: meta.jaegerPid, binary: meta.jaegerBinary, config: join(home, "telemetry-jaeger.yaml") },
|
|
14
|
+
{
|
|
15
|
+
pid: meta.otelcolPid,
|
|
16
|
+
binary: meta.otelcolBinary,
|
|
17
|
+
config: join(home, "telemetry-collector.yaml"),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const own = recorded
|
|
21
|
+
.filter((p) => isOwnProcess(p.pid, p.binary, p.config, deps))
|
|
22
|
+
.map((p) => p.pid);
|
|
23
|
+
const strangers = recorded
|
|
24
|
+
.filter((p) => deps.alive(p.pid) && !own.includes(p.pid))
|
|
25
|
+
.map((p) => p.pid);
|
|
26
|
+
for (const pid of own)
|
|
27
|
+
signal(deps, pid, "SIGTERM");
|
|
28
|
+
for (let waited = 0; waited < GRACE_MS && own.some((pid) => deps.alive(pid)); waited += POLL_MS) {
|
|
29
|
+
await deps.sleep(POLL_MS);
|
|
30
|
+
}
|
|
31
|
+
for (const pid of own.filter((p) => deps.alive(p)))
|
|
32
|
+
signal(deps, pid, "SIGKILL");
|
|
33
|
+
await deps.sleep(POLL_MS);
|
|
34
|
+
const stillRunning = own.filter((pid) => deps.alive(pid));
|
|
35
|
+
const stopped = stillRunning.length === 0;
|
|
36
|
+
return { stopped, clearRecord: stopped, signaled: own, strangers, stillRunning };
|
|
37
|
+
}
|
|
38
|
+
function signal(deps, pid, sig) {
|
|
39
|
+
try {
|
|
40
|
+
deps.kill(pid, sig);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// already gone
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* A deliberate mirror of `PACKAGED_PATHS` in `scripts/release-guard.sh` — bash
|
|
4
4
|
* cannot import this, so `version-state.test.ts` pins the two lists equal.
|
|
5
5
|
*/
|
|
6
|
-
export declare const PACKAGED_PATHS: readonly ["apps/indusk-mcp/src", "apps/indusk-mcp/skills", "apps/indusk-mcp/templates", "apps/indusk-mcp/hooks", "apps/indusk-mcp/lessons", "apps/indusk-mcp/extensions", "apps/indusk-mcp/package.json", "apps/indusk-admin"];
|
|
6
|
+
export declare const PACKAGED_PATHS: readonly ["apps/indusk-mcp/src", "apps/indusk-mcp/skills", "apps/indusk-mcp/templates", "apps/indusk-mcp/hooks", "apps/indusk-mcp/lessons", "apps/indusk-mcp/extensions", "apps/indusk-mcp/package.json", "apps/indusk-admin", "examples/seat-holds"];
|
|
7
7
|
export interface RepoVersionState {
|
|
8
8
|
/** `apps/indusk-mcp/package.json` version in the working tree. */
|
|
9
9
|
version: string;
|
|
@@ -42,6 +42,7 @@ export const PACKAGED_PATHS = [
|
|
|
42
42
|
"apps/indusk-mcp/extensions",
|
|
43
43
|
"apps/indusk-mcp/package.json",
|
|
44
44
|
"apps/indusk-admin",
|
|
45
|
+
"examples/seat-holds",
|
|
45
46
|
];
|
|
46
47
|
function readPackageVersion(pkgJsonPath) {
|
|
47
48
|
return JSON.parse(readFileSync(pkgJsonPath, "utf-8")).version;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Seat holds — the first promise"
|
|
3
|
+
date: 2026-10-07
|
|
4
|
+
status: accepted
|
|
5
|
+
workflow: feature
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Seat holds — Brief
|
|
9
|
+
|
|
10
|
+
The plan this example starts from: a seat page whose holds are released on
|
|
11
|
+
time, and that answers once deployed. It shipped with the example, already built; the next plans are yours.
|
|
12
|
+
|
|
13
|
+
## Expectations
|
|
14
|
+
|
|
15
|
+
None — this is the starting point of a demo, not a change to measure.
|
|
16
|
+
|
|
17
|
+
## Promises
|
|
18
|
+
|
|
19
|
+
### This plan makes
|
|
20
|
+
|
|
21
|
+
1. **`a-held-seat-is-released-in-time`** (behaviour). A seat that is held and not booked is released when its hold window passes, never late.
|
|
22
|
+
2. **`the-deployed-demo-page-answers`** (state). After the seat-holds example is deployed to Fly from its own config, its page answers at the deployed address.
|
|
23
|
+
|
|
24
|
+
### Existing promises
|
|
25
|
+
|
|
26
|
+
**Must not break**
|
|
27
|
+
|
|
28
|
+
None.
|
|
29
|
+
|
|
30
|
+
**Changes**
|
|
31
|
+
|
|
32
|
+
None.
|
|
33
|
+
|
|
34
|
+
**Replaces**
|
|
35
|
+
|
|
36
|
+
None.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: a-held-seat-is-released-in-time
|
|
3
|
+
kind: behaviour
|
|
4
|
+
lifetime: holds
|
|
5
|
+
state: enforced
|
|
6
|
+
domain: seats
|
|
7
|
+
owner: seat-holds
|
|
8
|
+
sites:
|
|
9
|
+
- src/telemetry.ts
|
|
10
|
+
tests:
|
|
11
|
+
- src/seats.test.ts
|
|
12
|
+
incidents: []
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
A seat that is held and not booked is released when its hold window passes, never late.
|
|
16
|
+
|
|
17
|
+
## History
|
|
18
|
+
- 2026-10-07 — shipped with the seat-holds example (InDusk demo-app-template), enforced: its rules are tested in `src/seats.test.ts`, and every release is marked in `src/telemetry.ts`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: the-deployed-demo-page-answers
|
|
3
|
+
kind: state
|
|
4
|
+
lifetime: holds
|
|
5
|
+
state: enforced
|
|
6
|
+
domain: seats
|
|
7
|
+
owner: seat-holds
|
|
8
|
+
sites:
|
|
9
|
+
- Dockerfile
|
|
10
|
+
- fly.toml
|
|
11
|
+
tests:
|
|
12
|
+
- scripts/smoke-deployed.mjs
|
|
13
|
+
incidents: []
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
After the seat-holds example is deployed to Fly from its own config, its page answers at the deployed address.
|
|
17
|
+
|
|
18
|
+
## History
|
|
19
|
+
- 2026-10-07 — shipped with the seat-holds example (InDusk demo-app-template), enforced: `npm run smoke` against the deployed address proves it, at every deploy.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# promise: the-deployed-demo-page-answers
|
|
2
|
+
# The seat-holds example as a container: the page on 8080, its spans sent to
|
|
3
|
+
# whatever OTEL_EXPORTER_OTLP_ENDPOINT names (your project's InDusk server).
|
|
4
|
+
FROM node:24-slim
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
COPY package.json ./
|
|
7
|
+
# Node 24 for npm 11: npm 10 fails to install this tree ("Cannot read
|
|
8
|
+
# properties of null (reading 'edgesOut')").
|
|
9
|
+
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error
|
|
10
|
+
COPY src ./src
|
|
11
|
+
COPY public ./public
|
|
12
|
+
ENV NODE_ENV=production PORT=8080 SEAT_HOLDS_ENV=production
|
|
13
|
+
EXPOSE 8080
|
|
14
|
+
CMD ["npm", "start", "--silent"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Seat holds
|
|
2
|
+
|
|
3
|
+
A small app with one promise you can break on cue. It is the example InDusk's
|
|
4
|
+
demo starts from.
|
|
5
|
+
|
|
6
|
+
Hold a seat on the page and it is yours for a few seconds. If you don't book
|
|
7
|
+
it, it is released, and the app promises that release is never late:
|
|
8
|
+
|
|
9
|
+
> **`a-held-seat-is-released-in-time`** — A seat that is held and not booked is released when its hold window passes, never late.
|
|
10
|
+
|
|
11
|
+
Every release is marked with plain OpenTelemetry on the span that does the
|
|
12
|
+
work: `indusk.promise = a-held-seat-is-released-in-time` and
|
|
13
|
+
`indusk.promise.outcome = upheld` or `violated`, with a symptom like
|
|
14
|
+
`seat 2 released 3.2 s late`. Nothing else in the app knows InDusk exists.
|
|
15
|
+
InDusk reads those marks from your local telemetry daemon (or your project's
|
|
16
|
+
server) and shows the promise holding, or broken.
|
|
17
|
+
|
|
18
|
+
## Run it
|
|
19
|
+
|
|
20
|
+
The quickest way is from InDusk:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @infinitedusky/indusk-mcp demo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That copies this example into `./seat-holds`, sets it up as an InDusk project,
|
|
27
|
+
starts the local telemetry daemon and the app, and prints the page's address
|
|
28
|
+
and the admin's.
|
|
29
|
+
|
|
30
|
+
To run it by hand instead:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm install
|
|
34
|
+
SEAT_HOLDS_FAULT_TOGGLE=1 \
|
|
35
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
|
|
36
|
+
pnpm start
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Break it
|
|
40
|
+
|
|
41
|
+
With `SEAT_HOLDS_FAULT_TOGGLE=1`, the page shows a **Break it** switch. Turn it
|
|
42
|
+
on and releases come late on purpose: the next lapsed hold is marked
|
|
43
|
+
`violated`, and InDusk shows the promise broken with the span that broke it.
|
|
44
|
+
Press **Stop the fault** and releases come on time again, but the promise
|
|
45
|
+
stays broken: it said *never*. It holds again once the break is recorded as an
|
|
46
|
+
incident and fixed.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
| Variable | Default | What it does |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `PORT` | `8080` | the page and the API |
|
|
53
|
+
| `SEAT_HOLDS_SEATS` | `8` | how many seats |
|
|
54
|
+
| `SEAT_HOLDS_WINDOW_MS` | `5000` | how long a hold lasts |
|
|
55
|
+
| `SEAT_HOLDS_TOLERANCE_MS` | `1500` | how late a release may be and still keep the promise |
|
|
56
|
+
| `SEAT_HOLDS_FAULT_TOGGLE` | unset | `1` shows the Break it switch |
|
|
57
|
+
| `SEAT_HOLDS_FAULT` | unset | `slow-release` starts with releases late |
|
|
58
|
+
| `SEAT_HOLDS_ENV` | `local` | `deployment.environment` on every span |
|
|
59
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | where spans go: the InDusk daemon, or your project's server |
|
|
60
|
+
|
|
61
|
+
## Deploy it
|
|
62
|
+
|
|
63
|
+
`fly.toml` and the `Dockerfile` run it on Fly.io as one machine that never
|
|
64
|
+
sleeps, sending its spans to your project's InDusk server:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
fly apps create <app>
|
|
68
|
+
fly secrets set \
|
|
69
|
+
OTEL_EXPORTER_OTLP_ENDPOINT="https://<your-indusk-server>" \
|
|
70
|
+
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<base64 of user:password>"
|
|
71
|
+
fly deploy --app <app> --ha=false
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The deployed page has no Break it switch. To break the promise in production
|
|
75
|
+
on purpose, set `SEAT_HOLDS_FAULT=slow-release` and deploy; unset it and deploy
|
|
76
|
+
again to fix it.
|
|
77
|
+
|
|
78
|
+
## What's in it
|
|
79
|
+
|
|
80
|
+
- `src/seats.ts` — the rules: hold, book, release, and whether a release was on time. The clock is an argument, so the tests never wait.
|
|
81
|
+
- `src/telemetry.ts` — OpenTelemetry, and one mark per release.
|
|
82
|
+
- `src/server.ts` — the page, the API, the fault switch, and the sweeper that releases lapsed holds once a second.
|
|
83
|
+
- `public/index.html` — the page.
|
|
84
|
+
- `.indusk/` — the example's promise and the plan it came from.
|
|
85
|
+
|
|
86
|
+
Seats live in memory, so a restart frees them all. That is fine for a demo and
|
|
87
|
+
wrong for anything else.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# promise: the-deployed-demo-page-answers
|
|
2
|
+
# The seat-holds example on Fly.io (demo-app-template D7). One machine that
|
|
3
|
+
# never sleeps: the sweeper releases holds once a second, and a stopped
|
|
4
|
+
# machine marks nothing.
|
|
5
|
+
#
|
|
6
|
+
# fly apps create <app> # not `fly launch`: it rewrites this file
|
|
7
|
+
# fly secrets set \
|
|
8
|
+
# OTEL_EXPORTER_OTLP_ENDPOINT="https://<your-indusk-server>:4318" \
|
|
9
|
+
# OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64 of user:password>"
|
|
10
|
+
# fly deploy --ha=false
|
|
11
|
+
#
|
|
12
|
+
# The page has no Break it switch here. To break the promise in production on
|
|
13
|
+
# purpose, deploy with SEAT_HOLDS_FAULT=slow-release, and deploy again without
|
|
14
|
+
# it to fix it.
|
|
15
|
+
|
|
16
|
+
app = "seat-holds"
|
|
17
|
+
primary_region = "iad"
|
|
18
|
+
|
|
19
|
+
[env]
|
|
20
|
+
PORT = "8080"
|
|
21
|
+
SEAT_HOLDS_ENV = "production"
|
|
22
|
+
|
|
23
|
+
[http_service]
|
|
24
|
+
internal_port = 8080
|
|
25
|
+
force_https = true
|
|
26
|
+
auto_stop_machines = "off"
|
|
27
|
+
auto_start_machines = true
|
|
28
|
+
min_machines_running = 1
|
|
29
|
+
|
|
30
|
+
[[vm]]
|
|
31
|
+
size = "shared-cpu-1x"
|
|
32
|
+
memory = "256mb"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seat-holds",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "A small app with one promise you can break on cue — the InDusk demo example",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "tsx src/server.ts",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"smoke": "node scripts/smoke-deployed.mjs"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@opentelemetry/api": "^1.9.0",
|
|
14
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
|
15
|
+
"@opentelemetry/resources": "^2.6.0",
|
|
16
|
+
"@opentelemetry/sdk-trace-base": "^2.6.0",
|
|
17
|
+
"@opentelemetry/sdk-trace-node": "^2.6.0",
|
|
18
|
+
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
19
|
+
"tsx": "^4.19.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vitest": "^4.1.11"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Seat holds</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#f7f6f2; --ink:#1c1b19; --muted:#6b675f; --line:#dedad0; --held:#c98a14; --booked:#2f6f4f; --bad:#b3261e; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin:0; padding:2rem 1rem; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui, sans-serif; }
|
|
11
|
+
main { max-width: 40rem; margin: 0 auto; }
|
|
12
|
+
h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
|
|
13
|
+
p.lede { color: var(--muted); margin: 0 0 1.5rem; }
|
|
14
|
+
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap:.75rem; }
|
|
15
|
+
.seat { background:#fff; border:1px solid var(--line); border-radius:.6rem; padding:.75rem; min-height:7rem; display:flex; flex-direction:column; gap:.4rem; }
|
|
16
|
+
.seat h2 { font-size:1rem; margin:0; }
|
|
17
|
+
.state { font-size:.85rem; color:var(--muted); }
|
|
18
|
+
.seat.held { border-color: var(--held); }
|
|
19
|
+
.seat.held .state { color: var(--held); }
|
|
20
|
+
.seat.booked { border-color: var(--booked); }
|
|
21
|
+
.seat.booked .state { color: var(--booked); }
|
|
22
|
+
button { font:inherit; padding:.35rem .7rem; border-radius:.4rem; border:1px solid var(--ink); background:var(--ink); color:#fff; cursor:pointer; }
|
|
23
|
+
button.secondary { background:#fff; color:var(--ink); }
|
|
24
|
+
.fault { margin: 1.5rem 0 0; padding: .75rem; border:1px dashed var(--line); border-radius:.6rem; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap; }
|
|
25
|
+
.fault.on { border-color: var(--bad); color: var(--bad); }
|
|
26
|
+
.fault button.on { background: var(--bad); border-color: var(--bad); }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<main>
|
|
31
|
+
<h1>Seat holds</h1>
|
|
32
|
+
<p class="lede">Hold a seat and it is yours for a few seconds. If you don't book it, it is released, and this app promises that release is never late.</p>
|
|
33
|
+
<div class="grid" id="seats"></div>
|
|
34
|
+
<div class="fault" id="fault" hidden>
|
|
35
|
+
<button id="toggle" type="button">Break it</button>
|
|
36
|
+
<span id="fault-text">Releases come on time. Break it to make them late, and watch the promise break.</span>
|
|
37
|
+
</div>
|
|
38
|
+
</main>
|
|
39
|
+
<script>
|
|
40
|
+
const who = "you";
|
|
41
|
+
const el = (id) => document.getElementById(id);
|
|
42
|
+
async function post(path, body) {
|
|
43
|
+
await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
44
|
+
render();
|
|
45
|
+
}
|
|
46
|
+
async function render() {
|
|
47
|
+
const { seats, fault, toggle } = await (await fetch("/seats")).json();
|
|
48
|
+
el("seats").replaceChildren(...seats.map((s) => {
|
|
49
|
+
const card = document.createElement("div");
|
|
50
|
+
card.className = `seat ${s.state}`;
|
|
51
|
+
const title = document.createElement("h2");
|
|
52
|
+
title.textContent = `Seat ${s.seat}`;
|
|
53
|
+
const state = document.createElement("div");
|
|
54
|
+
state.className = "state";
|
|
55
|
+
state.textContent = s.state === "held" ? `held · ${Math.ceil(s.remainingMs / 1000)} s left`
|
|
56
|
+
: s.state === "booked" ? "booked" : "free";
|
|
57
|
+
card.append(title, state);
|
|
58
|
+
if (s.state === "free") {
|
|
59
|
+
const b = document.createElement("button");
|
|
60
|
+
b.textContent = "Hold";
|
|
61
|
+
b.onclick = () => post("/hold", { seat: s.seat, who });
|
|
62
|
+
card.append(b);
|
|
63
|
+
} else if (s.state === "held" && s.who === who) {
|
|
64
|
+
const b = document.createElement("button");
|
|
65
|
+
b.className = "secondary";
|
|
66
|
+
b.textContent = "Book";
|
|
67
|
+
b.onclick = () => post("/book", { seat: s.seat, who });
|
|
68
|
+
card.append(b);
|
|
69
|
+
}
|
|
70
|
+
return card;
|
|
71
|
+
}));
|
|
72
|
+
el("fault").hidden = !toggle;
|
|
73
|
+
el("fault").className = `fault${fault ? " on" : ""}`;
|
|
74
|
+
el("toggle").className = fault ? "on" : "";
|
|
75
|
+
el("toggle").textContent = fault ? "Stop the fault" : "Break it";
|
|
76
|
+
el("toggle").onclick = () => post("/fault", { on: !fault });
|
|
77
|
+
el("fault-text").textContent = fault
|
|
78
|
+
? "Releases are late on purpose, and every late one breaks the promise. Stopping the fault does not mend it: the promise stays broken until the break is recorded and fixed."
|
|
79
|
+
: "Releases come on time. Break it to make them late, and watch the promise break.";
|
|
80
|
+
}
|
|
81
|
+
render();
|
|
82
|
+
setInterval(render, 500);
|
|
83
|
+
</script>
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// promise: the-deployed-demo-page-answers
|
|
3
|
+
//
|
|
4
|
+
// After the seat-holds example is deployed, its page answers at the deployed
|
|
5
|
+
// address. Run at deploy: SEAT_HOLDS_URL=https://<app>.fly.dev npm run smoke
|
|
6
|
+
// Exit 0 only on a 200 whose body is the seat page; otherwise say why and exit 1.
|
|
7
|
+
|
|
8
|
+
const url = process.env.SEAT_HOLDS_URL;
|
|
9
|
+
if (!url) {
|
|
10
|
+
console.error("smoke: SEAT_HOLDS_URL is not set — name the deployed address to check");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
16
|
+
const body = await res.text();
|
|
17
|
+
if (res.status !== 200) {
|
|
18
|
+
console.error(`smoke: ${url} answered ${res.status}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
if (!body.includes("<title>Seat holds</title>")) {
|
|
22
|
+
console.error(`smoke: ${url} answered 200, but not with the seat page`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
console.log(`smoke: ${url} answers with the seat page`);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(`smoke: ${url} did not answer — ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// promise: a-held-seat-is-released-in-time
|
|
2
|
+
// The rules behind this example's one promise. (In InDusk's repository these
|
|
3
|
+
// are rows A1, A2 and A6 of the demo-app-template plan.)
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { createSeats } from "./seats.js";
|
|
6
|
+
|
|
7
|
+
// The clock is an argument: every rule here runs in microseconds, never waits.
|
|
8
|
+
const opts = { seats: 4, windowMs: 3000, toleranceMs: 1000, faultDelayMs: 3000 };
|
|
9
|
+
|
|
10
|
+
describe("seat holds", () => {
|
|
11
|
+
it("A1: a held seat comes free when its window passes; a booked one never does; a held seat is not anyone else's", () => {
|
|
12
|
+
const s = createSeats(opts);
|
|
13
|
+
expect(s.hold(1, "ann", 0).ok).toBe(true);
|
|
14
|
+
expect(s.hold(1, "bob", 10).ok).toBe(false);
|
|
15
|
+
expect(s.hold(2, "bob", 0).ok).toBe(true);
|
|
16
|
+
expect(s.book(2, "bob", 100).ok).toBe(true);
|
|
17
|
+
expect(s.book(1, "bob", 100).ok, "only the holder books").toBe(false);
|
|
18
|
+
expect(s.sweep(2900, { fault: false })).toEqual([]);
|
|
19
|
+
expect(s.sweep(3100, { fault: false }).map((r) => r.seat)).toEqual([1]);
|
|
20
|
+
expect(s.sweep(60_000, { fault: false }), "a booked seat never comes free").toEqual([]);
|
|
21
|
+
expect(s.hold(1, "bob", 3200).ok, "a released seat can be held again").toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("A2: a release on time is held; a late one is broken, naming the seat and how late", () => {
|
|
25
|
+
const s = createSeats(opts);
|
|
26
|
+
s.hold(1, "ann", 0);
|
|
27
|
+
expect(s.sweep(3500, { fault: false })[0]).toMatchObject({ seat: 1, outcome: "upheld" });
|
|
28
|
+
s.hold(2, "bob", 0);
|
|
29
|
+
expect(s.sweep(6200, { fault: false })[0]).toMatchObject({
|
|
30
|
+
seat: 2,
|
|
31
|
+
outcome: "violated",
|
|
32
|
+
lateMs: 3200,
|
|
33
|
+
symptom: "seat 2 released 3.2 s late",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("A6: with the fault on, a release waits past its window and is broken; off, it is on time", () => {
|
|
38
|
+
const s = createSeats(opts);
|
|
39
|
+
s.hold(1, "ann", 0);
|
|
40
|
+
expect(s.sweep(3100, { fault: true }), "the fault holds the release back").toEqual([]);
|
|
41
|
+
expect(s.sweep(6100, { fault: true })[0]).toMatchObject({ seat: 1, outcome: "violated" });
|
|
42
|
+
s.hold(2, "bob", 7000);
|
|
43
|
+
expect(s.sweep(10_100, { fault: false })[0]).toMatchObject({ seat: 2, outcome: "upheld" });
|
|
44
|
+
});
|
|
45
|
+
});
|