Ragnarok rodecomp deployment on memento
Changes to upstream: - git.js: coordinator commit identity from TD_GIT_AUTHOR_NAME/EMAIL. - build.js: docker runner can share job dirs through a named volume (jobVolume/jobVolumeRoot) for containerized workers. - install.js: staged installer output with logs, start prompt for regions. - web: feed layout, cache-busting asset versions, multi-agent help section. - build-td: fetch checksummed Windows runtime when building on non-Windows. Additions: - deploy/rodecomp: compose stack, Dockerfiles, Caddyfile, server.json, bulk import/verify scripts, and README.md with the deployment notes (the root README stays identical to upstream). - toolchain/msvc-v142: MSVC v142 under Wine (Docker), SDK 10.0.19041.0. - tools/rodecomp: project preparation, Forgejo provisioning, history and identity tools.
This commit is contained in:
parent
804ebd15c2
commit
022e0a2c78
39 changed files with 1232 additions and 33 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.git
|
||||
data
|
||||
logs
|
||||
dist
|
||||
deploy/rodecomp/import
|
||||
toolchain
|
||||
13
deploy/rodecomp/Caddyfile
Normal file
13
deploy/rodecomp/Caddyfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
:8080 {
|
||||
redir /git /git/ 308
|
||||
handle_path /git/* {
|
||||
reverse_proxy forgejo:3000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy web:8080
|
||||
}
|
||||
}
|
||||
11
deploy/rodecomp/Dockerfile
Normal file
11
deploy/rodecomp/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM docker:29-cli AS docker-cli
|
||||
FROM node:24-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git binutils ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
COPY ghidra_scripts ./ghidra_scripts
|
||||
COPY template ./template
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["node", "src/server/cli.js"]
|
||||
8
deploy/rodecomp/Dockerfile.client
Normal file
8
deploy/rodecomp/Dockerfile.client
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FROM node:22.21.0-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
COPY ghidra_scripts ./ghidra_scripts
|
||||
COPY tools/build-td ./tools/build-td
|
||||
RUN node tools/build-td/build.mjs
|
||||
CMD ["cp", "-a", "/app/dist/.", "/output/"]
|
||||
3
deploy/rodecomp/Dockerfile.history
Normal file
3
deploy/rodecomp/Dockerfile.history
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
FROM rodecomp/server:local
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git-filter-repo && rm -rf /var/lib/apt/lists/*
|
||||
ENTRYPOINT ["git"]
|
||||
90
deploy/rodecomp/README.md
Normal file
90
deploy/rodecomp/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Ragnarok deployment on memento
|
||||
|
||||
Deployment files: `C:\Users\Memento\Desktop\rodecomp`; Compose file:
|
||||
`deploy/rodecomp/compose.yaml`. All services and compiler execution run in Linux
|
||||
Docker containers. Persistent repository, SQLite, targets and downloads live in
|
||||
the external Docker volume `rodecomp_data` (create it before Compose).
|
||||
|
||||
The pinned candidate toolchain is MSVC **19.29.30159**, v142, x86 target, Windows
|
||||
SDK **10.0.19041.0**, under Wine. This is consistent with Ragexe's linker 14.29
|
||||
and `Release V142 C++20` PDB path; the original exact compiler build is unknown.
|
||||
`toolchain/msvc-v142/Dockerfile` downloads Microsoft packages with SHA-256 checks
|
||||
inside its build stage. Check applicable Microsoft redistribution rights before
|
||||
exposing compiler downloads publicly.
|
||||
|
||||
The optional `runners.ragnarok.jobVolume` and `jobVolumeRoot` settings mount only
|
||||
the job subdirectory into sibling compiler containers using Docker's
|
||||
`volume-subpath` support. This avoids Docker Desktop host-path translation.
|
||||
Only the worker receives the Docker socket; this still grants it control over
|
||||
the Docker daemon. Compiler containers retain upstream network, capability,
|
||||
read-only-root and resource restrictions.
|
||||
|
||||
`tools/rodecomp/configure.sh` runs CMake in `rodecomp/configure:v142` to export
|
||||
real per-unit flags through `export-compile.py`. Runtime checks use upstream's
|
||||
direct compiler adapter, not CMake/CTest. Upstream matching and peer-review
|
||||
policy is unchanged. `tools/build-td/build.mjs` can now package both Windows and
|
||||
Linux clients from the Linux build container.
|
||||
|
||||
The Docker Caddy proxy binds **127.0.0.1:19580** on memento. Cloudflare Tunnel
|
||||
publishes `https://clank.rodecomp.online`; the native connector targets that
|
||||
localhost port. Caddy strips `/git/` and forwards it to Forgejo; other paths go
|
||||
to Clank. Generated installers use the configured public HTTPS hostname.
|
||||
Do not publish the Desktop directory or Docker socket.
|
||||
|
||||
From memento's Debian WSL, start/restart with:
|
||||
`docker compose -f /mnt/c/Users/Memento/Desktop/rodecomp/deploy/rodecomp/compose.yaml up -d`.
|
||||
Back up the volume with the services stopped, including SQLite and repository;
|
||||
copying only the Desktop deployment files does not back up runtime state.
|
||||
|
||||
Forgejo **15.0.10** (the version pinned by upstream's deployment script) serves
|
||||
`https://clank.rodecomp.online/git/clank/rodecomp`. Registration, SSH, Actions,
|
||||
packages and pull requests are disabled. The coordinator's `clank-bot` account
|
||||
is the only whitelisted pusher to protected `main`; `pushRemote=forgejo` publishes
|
||||
accepted merges. Forgejo accounts are separate from Clank accounts. Its random
|
||||
`clank-admin` credential is stored privately in `rodecomp_data/forgejo-admin.json`;
|
||||
the coordinator bot token stays in the canonical repository's `.git/config`.
|
||||
Back up **both** `rodecomp_data` and `rodecomp_forgejo`. Provisioning is implemented
|
||||
in `tools/rodecomp/provision-forgejo.mjs`, without changes to Clank source code.
|
||||
Verified public repository browsing, HTTPS clone and coordinator initial push.
|
||||
|
||||
The published project was reset to one root commit, `d456e8f64aa1b73279471149d8cd4d05c98eb541`,
|
||||
preserving the current tree. Existing contributor clones must be recreated.
|
||||
Compose sets `TD_GIT_AUTHOR_NAME` and `TD_GIT_AUTHOR_EMAIL` to `ragnarok-clanker`;
|
||||
the coordinator's optional identity override sets both author and committer for
|
||||
future commits. Without those variables, upstream identity behavior is unchanged.
|
||||
The private `rodecomp_history_backup` volume retains pre-rewrite recovery data;
|
||||
it is not published. Public old Git objects were pruned. Clank progress was preserved.
|
||||
|
||||
Installer presentation (`src/server/install.js` only): four numbered steps, a compact
|
||||
ASCII banner, color/status icons, explicit incomplete-installation reporting, and
|
||||
an MCP handoff. Detailed td output is retained in a temporary installation log with
|
||||
the supplied token redacted. `NO_COLOR` and redirected output disable colors.
|
||||
The displayed AI start prompt matches the guide exactly. The td client binaries,
|
||||
CLI help, and MCP protocol are unchanged by this presentation update.
|
||||
|
||||
Live feed navigation fix: reset its event cursor when replacing the initial feed
|
||||
snapshot on returning Home. The list fills its parent panel with internal scrolling;
|
||||
stacked mobile layout retains a viewport-height limit. Versioned frontend asset URLs
|
||||
avoid stale Cloudflare/browser scripts. Verified Home → Guide → Home retains events
|
||||
and desktop Live/Progress panel heights match.
|
||||
|
||||
Verified deployment: administrator browser login; generated guide and installers;
|
||||
fresh Linux installer clone/target hash/compiler download/MCP setup followed by
|
||||
`td check Source/Utility/XorBytes.cpp`; sandboxed server checks of XorBytes,
|
||||
GPak and BrowserClientSite; Windows standalone client's `--help` and PowerShell
|
||||
installer parsing. Windows full onboarding and all-source builds are not yet
|
||||
verified. The imported original-code cache contains 4,480 functions, not all
|
||||
45,446 function symbols; uncached pseudocode still needs an analysis provider.
|
||||
|
||||
Progress migration: upstream `td-server rebuild` measured the existing source.
|
||||
Historical confirmations are imported with `tools/rodecomp/import-confirmations.mjs`
|
||||
only when the entire source file matches its historical confirmed commit and the
|
||||
current rebuild emitted the function. The evidence export is retained under
|
||||
`deploy/rodecomp/import/confirmed.json`; migration records an audit event and does
|
||||
not manufacture reviews or recent merge credit. Initial refresh counted 189 exact
|
||||
and 453 additional peer-confirmed functions (642/43,147, 1.49%); 13 historical
|
||||
confirmations were deferred because current output was missing.
|
||||
The full rebuild exposed five failing units: FontCache (SDK min/max),
|
||||
RenderToTextureDX7 (missing wrl/client.h), TextureDX7 (v142 compiler internal error),
|
||||
and two Verification units absent from upstream's copied source directories.
|
||||
These are not counted as successful builds and were not changed by the progress migration.
|
||||
43
deploy/rodecomp/bulk-backup.cjs
Normal file
43
deploy/rodecomp/bulk-backup.cjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const root = '/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const repo = path.join(root, 'repo');
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const destination = path.join(root, 'admin-backups', `readable-source-${stamp}`);
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
|
||||
if (git('status', '--porcelain')) throw new Error('Canonical repository is dirty; no import performed');
|
||||
const db = new DatabaseSync(path.join(root, 'td.sqlite'));
|
||||
db.exec('PRAGMA busy_timeout=15000');
|
||||
const running = db.prepare("SELECT COUNT(*) n FROM check_jobs WHERE state IN ('queued','running')").get().n;
|
||||
const verifying = db.prepare("SELECT COUNT(*) n FROM batches WHERE state IN ('queued','verifying','merging')").get().n;
|
||||
if (running || verifying) throw new Error('Live verification/merge jobs exist; no import performed');
|
||||
const before = {
|
||||
stamp,
|
||||
head: git('rev-parse', 'HEAD'),
|
||||
tasks: db.prepare('SELECT state,count(*) n FROM tasks GROUP BY state').all(),
|
||||
attempts: db.prepare('SELECT state,kind,count(*) n FROM attempts GROUP BY state,kind').all(),
|
||||
batchCounts: db.prepare('SELECT state,count(*) n FROM batches GROUP BY state').all(),
|
||||
schemas: db.prepare("SELECT name,sql FROM sqlite_master WHERE type IN ('table','trigger')").all(),
|
||||
funcStatus: db.prepare('SELECT * FROM func_status').all(),
|
||||
symbols: db.prepare('SELECT id,unit,decorated,name,library FROM symbols').all(),
|
||||
tasksFull: db.prepare('SELECT * FROM tasks').all(),
|
||||
reviews: db.prepare('SELECT count(*) n FROM peer_reviews').get().n,
|
||||
humanReviews: db.prepare('SELECT count(*) n FROM reviews').get().n
|
||||
};
|
||||
db.prepare('VACUUM INTO ?').run(path.join(destination, 'td.sqlite'));
|
||||
execFileSync('git', ['bundle', 'create', path.join(destination, 'repo.bundle'), '--all'], { cwd: repo });
|
||||
execFileSync('tar', ['-czf', path.join(destination, 'repo.tar.gz'), '-C', root, 'repo']);
|
||||
fs.copyFileSync(path.join(root, 'server.json'), path.join(destination, 'server.json'));
|
||||
fs.copyFileSync('/tmp/bulk-import-payload.json', path.join(destination, 'payload.json'));
|
||||
fs.writeFileSync(path.join(destination, 'before.json'), JSON.stringify(before, null, 2));
|
||||
const integrity = new DatabaseSync(path.join(destination, 'td.sqlite'), { readOnly: true });
|
||||
if (integrity.prepare('PRAGMA integrity_check').get().integrity_check !== 'ok') throw new Error('Backup database integrity failed');
|
||||
integrity.close();
|
||||
const result = { destination, head: before.head, reviews: before.reviews, humanReviews: before.humanReviews, payloadSha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(destination, 'payload.json'))).digest('hex'), backupIntegrity: 'ok' };
|
||||
fs.writeFileSync(path.join(root, 'bulk-import-backup-latest.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
db.close();
|
||||
63
deploy/rodecomp/bulk-finalize.mjs
Normal file
63
deploy/rodecomp/bulk-finalize.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { openServer } from '/app/src/server/app.js';
|
||||
|
||||
const data = '/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const ctx = openServer(data);
|
||||
const store = ctx.store;
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/payload.json'), 'utf8'));
|
||||
const installed = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/install-result.json'), 'utf8'));
|
||||
const registered = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/register-result.json'), 'utf8'));
|
||||
if (ctx.repo.head() !== installed.commit) throw new Error('Import head changed');
|
||||
const current = new Set(Object.keys(payload.functions));
|
||||
const legacy = store.all("SELECT a.id,a.kind,a.batch_id,a.task_id,t.symbol_id FROM attempts a JOIN tasks t ON t.id=a.task_id WHERE a.state IN ('queued','verifying','peer_review','in_review','approved','merging')").filter(attempt => attempt.kind === 'code' && !current.has(attempt.symbol_id));
|
||||
const reason = 'Historical submission is preserved but is not a definition in the imported current source. Its old file snapshot could resurrect pre-refactor paths/interfaces. Rebase the missing definition onto the imported source before resubmitting; no review approval has been inferred.';
|
||||
store.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const attempt of legacy) {
|
||||
store.run("UPDATE attempts SET state='needs_cleanup',result_json=json_set(COALESCE(result_json,'{}'),'$.sourceMigration',json(?)),finished_at=? WHERE id=?", JSON.stringify({ commit: installed.commit, reason, originalHistoryPreserved: true }), Date.now(), attempt.id);
|
||||
store.run('DELETE FROM peer_leases WHERE attempt_id=?', attempt.id);
|
||||
store.run("UPDATE tasks SET state='needs_cleanup',lease_user=NULL,lease_expires=NULL,updated_at=? WHERE id=? AND state NOT IN ('done','skipped')", Date.now(), attempt.task_id);
|
||||
}
|
||||
for (const id of new Set(legacy.map(attempt => attempt.batch_id).filter(Boolean))) {
|
||||
const live = store.get("SELECT 1 FROM attempts WHERE batch_id=? AND state IN ('queued','verifying','peer_review','in_review','approved','merging')", id);
|
||||
if (!live) {
|
||||
store.run("UPDATE batches SET state='returned',result_json=json_set(COALESCE(result_json,'{}'),'$.sourceMigration',json(?)),finished_at=? WHERE id=?", JSON.stringify({ commit: installed.commit, reason, originalHistoryPreserved: true }), Date.now(), id);
|
||||
store.run('DELETE FROM batch_checks WHERE batch_id=?', id);
|
||||
}
|
||||
}
|
||||
store.run('INSERT INTO events(at,user_id,type,data) VALUES(?,?,?,?)', Date.now(), 1, 'admin_source_migration_legacy_quarantine', JSON.stringify({ commit: installed.commit, attempts: legacy, reason }));
|
||||
store.db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
store.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
const mismatches = [];
|
||||
for (const [file, value] of Object.entries(payload.files)) {
|
||||
const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(ctx.repo.dir, file))).digest('hex');
|
||||
if (hash !== value.sha256) mismatches.push(file);
|
||||
}
|
||||
const incomplete = registered.rows.filter(row => !store.get('SELECT 1 FROM attempts a JOIN tasks t ON t.id=a.task_id WHERE a.id=? AND t.symbol_id=?', row.attemptId, row.symbol));
|
||||
const staleSymbols = store.all('SELECT id,unit FROM symbols WHERE unit IS NOT NULL').filter(row => Object.hasOwn(payload.moves, row.unit));
|
||||
if (mismatches.length || incomplete.length || staleSymbols.length) throw new Error('Import consistency verification failed');
|
||||
const integrity = store.get('PRAGMA integrity_check').integrity_check;
|
||||
if (integrity !== 'ok' || store.all('PRAGMA foreign_key_check').length) throw new Error('Database integrity verification failed');
|
||||
const summary = {
|
||||
commit: installed.commit,
|
||||
payloadFiles: Object.keys(payload.files).length,
|
||||
sourceFunctions: registered.rows.length,
|
||||
counts: registered.counts,
|
||||
legacyCodeNeedsRebase: legacy,
|
||||
legacySkipClaimsPreserved: store.get("SELECT count(*) n FROM attempts WHERE state='peer_review' AND kind='skip'").n,
|
||||
sourceHashMismatches: mismatches,
|
||||
missingSubmissionRecords: incomplete,
|
||||
staleSymbolUnitPaths: staleSymbols,
|
||||
databaseIntegrity: integrity,
|
||||
databaseForeignKeys: 'ok',
|
||||
repositoryClean: ctx.repo.isClean(),
|
||||
taskStates: store.all('SELECT state,count(*) n FROM tasks GROUP BY state'),
|
||||
attemptStates: store.all('SELECT state,count(*) n FROM attempts GROUP BY state')
|
||||
};
|
||||
fs.writeFileSync(path.join(data, 'admin-bulk/final-result.json'), JSON.stringify(summary, null, 2));
|
||||
console.log(JSON.stringify({ ...summary, legacyCodeNeedsRebase: legacy.length }, null, 2));
|
||||
70
deploy/rodecomp/bulk-install.mjs
Normal file
70
deploy/rodecomp/bulk-install.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const data = '/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const repo = path.join(data, 'repo');
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/payload.json'), 'utf8'));
|
||||
const backup = JSON.parse(fs.readFileSync(path.join(data, 'bulk-import-backup-latest.json'), 'utf8'));
|
||||
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
|
||||
if (git('rev-parse', 'HEAD') !== payload.baseCommit || backup.head !== payload.baseCommit) throw new Error('Remote base changed; import not applied');
|
||||
if (git('status', '--porcelain')) throw new Error('Remote repository dirty; import not applied');
|
||||
const db = new DatabaseSync(path.join(data, 'td.sqlite'));
|
||||
db.exec('PRAGMA busy_timeout=15000; PRAGMA foreign_keys=ON');
|
||||
if (db.prepare("SELECT count(*) n FROM check_jobs WHERE state IN ('queued','running')").get().n) throw new Error('Check still active');
|
||||
if (db.prepare("SELECT count(*) n FROM batches WHERE state IN ('queued','verifying','merging')").get().n) throw new Error('Batch still active');
|
||||
const annotationUnits = new Map();
|
||||
for (const [id, locations] of Object.entries(payload.functions)) {
|
||||
const symbol = db.prepare('SELECT * FROM symbols WHERE id=?').get(id);
|
||||
if (!symbol || symbol.library) throw new Error(`Unexpected import symbol ${id}`);
|
||||
const candidates = locations.filter(location => /\.(c|cc|cpp|cxx)$/.test(location.file));
|
||||
const mapped = payload.moves[symbol.unit] ?? symbol.unit;
|
||||
const unit = candidates.find(location => location.file === mapped)?.file ?? candidates[0]?.file ?? mapped;
|
||||
if (unit) annotationUnits.set(id, unit);
|
||||
}
|
||||
const tracked = git('ls-files', '--', 'Source').split('\n').filter(Boolean);
|
||||
const removed = tracked.filter(file => !payload.files[file]);
|
||||
for (const file of removed) fs.unlinkSync(path.join(repo, file));
|
||||
for (const [file, value] of Object.entries(payload.files)) {
|
||||
if (path.isAbsolute(file) || file.split('/').includes('..')) throw new Error(`Unsafe path ${file}`);
|
||||
if (crypto.createHash('sha256').update(value.text).digest('hex') !== value.sha256) throw new Error(`Payload checksum mismatch ${file}`);
|
||||
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, file), value.text);
|
||||
}
|
||||
git('add', '-A', '--', 'Source', 'CODING_RULES.md', '.clang-format', 'CMakeLists.txt', 'compile.json', 'README.md');
|
||||
git('-c', 'user.name=ragnarok-clanker', '-c', 'user.email=ragnarok-clanker@users.clank-together.invalid', 'commit', '-m', `${payload.message}\n\nAdministrative source import: native x86 compilation passed for 423 authored units. Source placement and submission states are migrated separately; this commit does not imply peer acceptance.\n\nPrevious head: ${payload.baseCommit}\nPayload SHA256: ${backup.payloadSha256}`);
|
||||
const commit = git('rev-parse', 'HEAD');
|
||||
const changes = { symbolsMoved: 0, symbolsPlacedFromAnnotations: 0, funcStatusPaths: 0, dependencyPaths: 0, regionPaths: 0 };
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const [oldPath, newPath] of Object.entries(payload.moves)) {
|
||||
changes.symbolsMoved += Number(db.prepare('UPDATE symbols SET unit=? WHERE unit=?').run(newPath, oldPath).changes);
|
||||
changes.funcStatusPaths += Number(db.prepare('UPDATE func_status SET file=? WHERE file=?').run(newPath, oldPath).changes);
|
||||
changes.regionPaths += Number(db.prepare("UPDATE region_claims SET key=? WHERE kind='unit' AND key=?").run(newPath, oldPath).changes);
|
||||
}
|
||||
for (const [id, unit] of annotationUnits) changes.symbolsPlacedFromAnnotations += Number(db.prepare('UPDATE symbols SET unit=? WHERE id=? AND (unit IS NULL OR unit!=?)').run(unit, id, unit).changes);
|
||||
const dependencies = db.prepare('SELECT * FROM unit_deps').all();
|
||||
db.exec('DELETE FROM unit_deps');
|
||||
for (const dependency of dependencies) {
|
||||
const unit = payload.moves[dependency.unit] ?? dependency.unit;
|
||||
const header = payload.moves[dependency.header] ?? dependency.header;
|
||||
if (unit !== dependency.unit || header !== dependency.header) changes.dependencyPaths++;
|
||||
db.prepare('INSERT OR IGNORE INTO unit_deps(unit,header) VALUES(?,?)').run(unit, header);
|
||||
}
|
||||
db.prepare('INSERT INTO events(at,user_id,type,data) VALUES(?,?,?,?)').run(Date.now(), 1, 'admin_source_import', JSON.stringify({ commit, baseCommit: payload.baseCommit, sourceFiles: 791, moves: Object.keys(payload.moves).length, backup: backup.destination, changes }));
|
||||
db.prepare("INSERT INTO meta(key,value) VALUES('admin_source_import',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify({ commit, backup: backup.destination, at: Date.now(), state: 'installed_pending_verification' }));
|
||||
const bad = db.prepare('PRAGMA foreign_key_check').all();
|
||||
if (bad.length) throw new Error(`Foreign key check failed: ${JSON.stringify(bad)}`);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
const missing = Object.entries(payload.files).filter(([file, value]) => crypto.createHash('sha256').update(fs.readFileSync(path.join(repo, file))).digest('hex') !== value.sha256);
|
||||
if (missing.length) throw new Error('Installed file checksum mismatch');
|
||||
const result = { commit, previous: payload.baseCommit, removedOldSourcePaths: removed.length, payloadFiles: Object.keys(payload.files).length, fileChecksumMismatches: missing.length, backup: backup.destination, changes, repositoryClean: !git('status', '--porcelain') };
|
||||
fs.writeFileSync(path.join(data, 'admin-bulk/install-result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
db.close();
|
||||
130
deploy/rodecomp/bulk-register.mjs
Normal file
130
deploy/rodecomp/bulk-register.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { openServer } from '/app/src/server/app.js';
|
||||
import { bestResults } from '/app/src/server/verify.js';
|
||||
|
||||
const data = '/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const ctx = openServer(data);
|
||||
const store = ctx.store;
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/payload.json'), 'utf8'));
|
||||
const installed = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/install-result.json'), 'utf8'));
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(data, 'admin-bulk/full-check.json'), 'utf8'));
|
||||
const backup = JSON.parse(fs.readFileSync(path.join(data, 'bulk-import-backup-latest.json'), 'utf8'));
|
||||
const before = JSON.parse(fs.readFileSync(path.join(backup.destination, 'before.json'), 'utf8'));
|
||||
if (ctx.repo.head() !== installed.commit) throw new Error('Repository changed since verification');
|
||||
if (saved.check.units.length !== 423 || saved.check.units.some(unit => !unit.compiled)) throw new Error('Imported source is not fully compiled');
|
||||
if (saved.rec.conflicts.some(conflict => conflict.hard)) throw new Error('Hard symbol-reference conflicts remain');
|
||||
const user = store.get("SELECT id FROM users WHERE name='ragnarok-clanker'");
|
||||
if (!user) throw new Error('Import account not found');
|
||||
const allIds = Object.keys(payload.functions);
|
||||
const wanted = new Set(allIds);
|
||||
const best = bestResults(saved.check);
|
||||
const previousStatus = new Map(before.funcStatus.map(row => [row.symbol_id, row]));
|
||||
const now = Date.now();
|
||||
const note = `Administrative import of the exact current source snapshot at ${installed.commit}. Remote Clank compiler passed all 423 authored units. The source is installed by an owner-authorized repository migration; pending logic still requires independent review. No peer verdict or confirmation is fabricated.`;
|
||||
const fileText = Object.fromEntries(Object.entries(payload.files).map(([file, value]) => [file, value.text]));
|
||||
const pendingBefore = store.all("SELECT a.*,t.symbol_id FROM attempts a JOIN tasks t ON t.id=a.task_id WHERE a.state IN ('queued','verifying','peer_review','in_review','approved','merging')");
|
||||
const rows = [];
|
||||
const superseded = [];
|
||||
const counts = {};
|
||||
const createdBatches = [];
|
||||
store.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const attempt of pendingBefore.filter(attempt => wanted.has(attempt.symbol_id))) {
|
||||
store.run("UPDATE attempts SET state='superseded',result_json=json_set(COALESCE(result_json,'{}'),'$.supersededBySourceImport',?),finished_at=? WHERE id=?", installed.commit, now, attempt.id);
|
||||
store.run('DELETE FROM peer_leases WHERE attempt_id=?', attempt.id);
|
||||
superseded.push(attempt.id);
|
||||
}
|
||||
const batchFor = new Map();
|
||||
for (const id of allIds) {
|
||||
const result = best.get(id);
|
||||
const locations = payload.functions[id];
|
||||
const symbol = store.get('SELECT * FROM symbols WHERE id=?', id);
|
||||
const task = store.get('SELECT * FROM tasks WHERE symbol_id=?', id);
|
||||
if (!task || !symbol || symbol.library) throw new Error(`Import tracking prerequisite missing for ${id}`);
|
||||
const unit = result?.unit ?? locations.find(location => location.file.endsWith('.cpp'))?.file ?? symbol.unit;
|
||||
const target = result?.f ? { ...result.f, file: unit } : null;
|
||||
const status = target?.status ?? 'missing';
|
||||
const score = target?.score ?? 0;
|
||||
let state;
|
||||
let summary;
|
||||
if (status === 'exact') {
|
||||
state = 'merged';
|
||||
summary = 'Administrative source import; remote compiler comparison is byte-identical. No peer confirmation claimed.';
|
||||
} else if (status === 'mismatch' || status === 'match_unresolved') {
|
||||
state = 'peer_review';
|
||||
summary = `Administrative source import; compiles, not byte-identical (score ${(score * 100).toFixed(1)}%). Awaiting independent peer review.`;
|
||||
} else {
|
||||
state = 'needs_cleanup';
|
||||
summary = 'Administrative source import registered; native C++ compiles, but this annotation did not resolve to an emitted function. No correctness or acceptance claimed.';
|
||||
}
|
||||
const groupKey = `${state}:${unit}`;
|
||||
let batchId = batchFor.get(groupKey);
|
||||
if (!batchId) {
|
||||
const batchFiles = {};
|
||||
for (const location of locations) batchFiles[location.file] = fileText[location.file];
|
||||
if (fileText[unit]) batchFiles[unit] = fileText[unit];
|
||||
const resultJson = { adminSourceImport: { commit: installed.commit, backup: backup.destination }, mergedFiles: batchFiles, changedHeaders: [], outside: [] };
|
||||
const inserted = store.run("INSERT INTO batches(user_id,token_id,base_commit,files_json,notes,agent_meta,env_digest,state,result_json,merged_commit,created_at,finished_at,cost) VALUES(?,NULL,?,?,?,?,?,?,?,?,?,?,?)", user.id, installed.commit, JSON.stringify(batchFiles), note, JSON.stringify({ source: 'owner-authorized-bulk-import', codeInstalled: true }), ctx.config.envDigest, state === 'merged' ? 'merged' : 'open', JSON.stringify(resultJson), state === 'merged' ? installed.commit : null, now, now, 1);
|
||||
batchId = Number(inserted.lastInsertRowid);
|
||||
batchFor.set(groupKey, batchId);
|
||||
createdBatches.push(batchId);
|
||||
} else {
|
||||
const row = store.get('SELECT files_json,result_json FROM batches WHERE id=?', batchId);
|
||||
const files = JSON.parse(row.files_json);
|
||||
for (const location of locations) files[location.file] = fileText[location.file];
|
||||
const results = JSON.parse(row.result_json);
|
||||
results.mergedFiles = files;
|
||||
store.run('UPDATE batches SET files_json=?,result_json=? WHERE id=?', JSON.stringify(files), JSON.stringify(results), batchId);
|
||||
}
|
||||
const feedback = {
|
||||
summary,
|
||||
target,
|
||||
annotationErrors: saved.check.annotationErrors.filter(error => error.severity !== 'warning' && error.file === unit),
|
||||
compileErrors: [],
|
||||
regressions: [],
|
||||
referenceConflicts: [],
|
||||
warnings: ['Source was installed by administrative import; peer approval has not been inferred from repository presence.'],
|
||||
annotationDiagnostics: saved.check.units.flatMap(compiled => compiled.errors).filter(error => String(error.message).startsWith(id + ':')),
|
||||
headCommit: installed.commit,
|
||||
importPreviousStatus: previousStatus.get(id)?.status ?? null
|
||||
};
|
||||
const attemptId = Number(store.run("INSERT INTO attempts(task_id,user_id,token_id,base_commit,files_json,notes,agent_meta,env_digest,state,score,result_json,merged_commit,created_at,finished_at,batch_id,competing,kind,peer_skipped) VALUES(?,?,NULL,?,'{}',?,?,?,?,?,?,?,?,?,?,0,'code',0)", task.id, user.id, installed.commit, note, JSON.stringify({ source: 'owner-authorized-bulk-import' }), ctx.config.envDigest, state, score, JSON.stringify({ feedback, adminSourceImport: { commit: installed.commit, backup: backup.destination } }), state === 'merged' ? installed.commit : null, now, now, batchId).lastInsertRowid);
|
||||
store.run("UPDATE tasks SET state='open',lease_user=NULL,lease_expires=NULL,lease_base_commit=NULL,updated_at=? WHERE id=?", now, task.id);
|
||||
store.run('UPDATE tasks SET state=?,best_score=?,best_attempt=?,preferred_user=?,preferred_until=NULL,updated_at=? WHERE id=?', state === 'merged' ? 'done' : state, score, attemptId, user.id, now, task.id);
|
||||
store.run('UPDATE symbols SET unit=? WHERE id=?', unit, id);
|
||||
store.run("INSERT INTO func_status(symbol_id,file,status,score,commit_hash,updated_at,confirmed,code_hash) VALUES(?,?,?,?,?,?,0,?) ON CONFLICT(symbol_id) DO UPDATE SET file=excluded.file,status=excluded.status,score=excluded.score,commit_hash=excluded.commit_hash,updated_at=excluded.updated_at,confirmed=0,code_hash=excluded.code_hash", id, unit, status, score, installed.commit, now, target?.codeHash ?? null);
|
||||
counts[state] = (counts[state] ?? 0) + 1;
|
||||
rows.push({ symbol: id, taskId: task.id, attemptId, batchId, unit, state, status, score });
|
||||
}
|
||||
for (const batchId of new Set(pendingBefore.filter(attempt => wanted.has(attempt.symbol_id)).map(attempt => attempt.batch_id).filter(Boolean))) {
|
||||
if (!store.get("SELECT 1 FROM attempts WHERE batch_id=? AND state IN ('queued','verifying','peer_review','in_review','approved','merging')", batchId)) {
|
||||
store.run("UPDATE batches SET state='superseded',result_json=json_set(COALESCE(result_json,'{}'),'$.supersededBySourceImport',?),finished_at=? WHERE id=?", installed.commit, now, batchId);
|
||||
store.run('DELETE FROM batch_checks WHERE batch_id=?', batchId);
|
||||
}
|
||||
}
|
||||
for (const unit of saved.check.units) {
|
||||
store.run('DELETE FROM unit_deps WHERE unit=?', unit.file);
|
||||
for (const header of unit.includes ?? []) store.run('INSERT OR IGNORE INTO unit_deps(unit,header) VALUES(?,?)', unit.file, header);
|
||||
}
|
||||
for (const batchId of createdBatches) {
|
||||
const batch = store.get('SELECT files_json,state FROM batches WHERE id=?', batchId);
|
||||
if (batch.state !== 'open') continue;
|
||||
const files = JSON.parse(batch.files_json);
|
||||
const selectedUnits = saved.check.units.filter(unit => Object.hasOwn(files, unit.file));
|
||||
const result = { check: { ...saved.check, units: selectedUnits }, rec: saved.rec, changedHeaders: [] };
|
||||
ctx.verifier.rememberCheck(batchId, result);
|
||||
}
|
||||
store.run('INSERT INTO events(at,user_id,type,data) VALUES(?,?,?,?)', now, user.id, 'admin_bulk_submitted', JSON.stringify({ commit: installed.commit, functions: rows.length, counts, superseded, batches: createdBatches }));
|
||||
store.run("INSERT INTO meta(key,value) VALUES('admin_source_import',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", JSON.stringify({ commit: installed.commit, backup: backup.destination, state: 'registered', functions: rows.length, counts, at: now }));
|
||||
const missing = rows.filter(row => !store.get('SELECT 1 FROM attempts a JOIN tasks t ON t.id=a.task_id WHERE a.id=? AND t.symbol_id=?', row.attemptId, row.symbol));
|
||||
if (missing.length) throw new Error('Submission coverage incomplete');
|
||||
if (store.all('PRAGMA foreign_key_check').length) throw new Error('Foreign key check failed');
|
||||
store.db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
store.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
const result = { commit: installed.commit, sourceFunctions: allIds.length, registered: rows.length, counts, supersededAttempts: superseded, createdBatches, rows, reviewsPreserved: store.get('SELECT count(*) n FROM peer_reviews').n === before.reviews, humanReviewsPreserved: store.get('SELECT count(*) n FROM reviews').n === before.humanReviews, databaseIntegrity: store.get('PRAGMA integrity_check').integrity_check };
|
||||
fs.writeFileSync(path.join(data, 'admin-bulk/register-result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify({ ...result, rows: undefined, createdBatches: createdBatches.length, supersededAttempts: superseded.length }, null, 2));
|
||||
26
deploy/rodecomp/bulk-verify.mjs
Normal file
26
deploy/rodecomp/bulk-verify.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { openServer } from '/app/src/server/app.js';
|
||||
|
||||
const data = '/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const ctx = openServer(data);
|
||||
const units = Object.keys(ctx.project.compile.units).filter(file => file.startsWith('Source/'));
|
||||
const started = Date.now();
|
||||
console.log(JSON.stringify({ phase: 'compile-start', units: units.length, head: ctx.repo.head(), at: started }));
|
||||
const result = await ctx.verifier.check({}, 'admin-readable-source-import', { onlyUnits: units, lint: false, timeoutMs: 6 * 3600_000 });
|
||||
const check = result.check;
|
||||
fs.writeFileSync(path.join(data, 'admin-bulk/full-check.json'), JSON.stringify({ check, rec: result.rec, changedHeaders: [] }));
|
||||
const summary = {
|
||||
phase: 'compile-complete',
|
||||
commit: ctx.repo.head(),
|
||||
units: check.units.length,
|
||||
compiled: check.units.filter(unit => unit.compiled).length,
|
||||
failed: check.units.filter(unit => !unit.compiled).map(unit => ({ file: unit.file, log: unit.log })),
|
||||
annotationErrors: check.annotationErrors,
|
||||
unitDiagnostics: check.units.flatMap(unit => unit.errors.map(error => ({ file: unit.file, ...error }))),
|
||||
referenceConflicts: result.rec.conflicts,
|
||||
seconds: (Date.now() - started) / 1000
|
||||
};
|
||||
fs.writeFileSync(path.join(data, 'admin-bulk/verify-result.json'), JSON.stringify(summary, null, 2));
|
||||
console.log(JSON.stringify({ ...summary, annotationErrors: summary.annotationErrors.length, unitDiagnostics: summary.unitDiagnostics.length, referenceConflicts: summary.referenceConflicts.length }, null, 2));
|
||||
process.exitCode = summary.failed.length ? 1 : 0;
|
||||
67
deploy/rodecomp/compose.yaml
Normal file
67
deploy/rodecomp/compose.yaml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: rodecomp
|
||||
services:
|
||||
web:
|
||||
image: rodecomp/server:local
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/rodecomp/Dockerfile
|
||||
command: [start, web, --data, /var/lib/docker/volumes/rodecomp_data/_data]
|
||||
environment:
|
||||
TD_GIT_AUTHOR_NAME: ragnarok-clanker
|
||||
TD_GIT_AUTHOR_EMAIL: ragnarok-clanker
|
||||
volumes:
|
||||
- data:/var/lib/docker/volumes/rodecomp_data/_data
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
worker:
|
||||
image: rodecomp/server:local
|
||||
command: [start, worker, --data, /var/lib/docker/volumes/rodecomp_data/_data]
|
||||
environment:
|
||||
TD_GIT_AUTHOR_NAME: ragnarok-clanker
|
||||
TD_GIT_AUTHOR_EMAIL: ragnarok-clanker
|
||||
volumes:
|
||||
- data:/var/lib/docker/volumes/rodecomp_data/_data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
stop_grace_period: 20m
|
||||
proxy:
|
||||
image: caddy:2-alpine
|
||||
ports:
|
||||
- "127.0.0.1:19580:8080"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
depends_on:
|
||||
- web
|
||||
- forgejo
|
||||
restart: unless-stopped
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:15.0.10
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
FORGEJO__server__ROOT_URL: "https://clank.rodecomp.online/git/"
|
||||
FORGEJO__server__DOMAIN: "clank.rodecomp.online"
|
||||
FORGEJO__server__DISABLE_SSH: "true"
|
||||
FORGEJO__server__OFFLINE_MODE: "true"
|
||||
FORGEJO__server__LANDING_PAGE: "explore"
|
||||
FORGEJO__database__DB_TYPE: "sqlite3"
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
FORGEJO__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "false"
|
||||
FORGEJO__picture__DISABLE_GRAVATAR: "true"
|
||||
FORGEJO__picture__ENABLE_FEDERATED_AVATAR: "false"
|
||||
FORGEJO__session__COOKIE_SECURE: "true"
|
||||
FORGEJO__actions__ENABLED: "false"
|
||||
FORGEJO__packages__ENABLED: "false"
|
||||
FORGEJO__repository__DISABLED_REPO_UNITS: "repo.pulls,repo.actions,repo.packages,repo.releases,repo.projects"
|
||||
volumes:
|
||||
- forgejo:/data
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
data:
|
||||
name: rodecomp_data
|
||||
external: true
|
||||
forgejo:
|
||||
name: rodecomp_forgejo
|
||||
25
deploy/rodecomp/server.json
Normal file
25
deploy/rodecomp/server.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"listen": { "host": "0.0.0.0", "port": 8080 },
|
||||
"publicUrl": "https://clank.rodecomp.online",
|
||||
"verifyConcurrency": 1,
|
||||
"remoteChecks": false,
|
||||
"buildTimeoutMs": 300000,
|
||||
"runners": {
|
||||
"ragnarok": {
|
||||
"type": "docker",
|
||||
"image": "rodecomp/compiler:v142",
|
||||
"toolchainRoot": "Z:\\opt\\toolchain",
|
||||
"jobVolume": "rodecomp_data",
|
||||
"jobVolumeRoot": "/var/lib/docker/volumes/rodecomp_data/_data",
|
||||
"memory": "2g",
|
||||
"cpus": 2,
|
||||
"jobs": 2
|
||||
}
|
||||
},
|
||||
"allowUnsandboxedRunners": false,
|
||||
"distributeTarget": true,
|
||||
"pushRemote": "forgejo",
|
||||
"gitCloneUrl": "https://clank.rodecomp.online/git/clank/rodecomp.git",
|
||||
"gitWebUrl": "https://clank.rodecomp.online/git/clank/rodecomp",
|
||||
"trustProxy": false
|
||||
}
|
||||
|
|
@ -122,7 +122,11 @@ export class Repo {
|
|||
this.git(['add', '--', ...Object.keys(files)]);
|
||||
// Nothing to commit (the code is already on main, e.g. a function resubmitted unchanged).
|
||||
if (!this.git(['status', '--porcelain', '--', ...Object.keys(files)]).trim()) return this.head();
|
||||
this.git(['-c', 'user.name=Clank Together', '-c', 'user.email=bot@clank-together.invalid', 'commit', '-q', '--author', author, '-F', '-'], { input: message });
|
||||
const name = process.env.TD_GIT_AUTHOR_NAME;
|
||||
const email = process.env.TD_GIT_AUTHOR_EMAIL;
|
||||
if (Boolean(name) !== Boolean(email)) throw new Error('set both TD_GIT_AUTHOR_NAME and TD_GIT_AUTHOR_EMAIL');
|
||||
const identity = name ? `${name} <${email}>` : author;
|
||||
this.git(['-c', `user.name=${name ?? 'Clank Together'}`, '-c', `user.email=${email ?? 'bot@clank-together.invalid'}`, 'commit', '-q', '--author', identity, '-F', '-'], { input: message });
|
||||
return this.head();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ function checksums(config) {
|
|||
|
||||
// What to tell the AI to get going; the website shows the same message.
|
||||
const START_PROMPT =
|
||||
"Read AGENTS.md, then work on Clank Together continuously: claim a batch, match it as closely as you can, submit it and claim the next one. Do peer reviews in between when any are waiting. Don't stop to ask me between tasks.";
|
||||
"Read AGENTS.md, then work on Clank Together continuously: run td_toolchain_install if you have not yet, pick a free region with td_regions, claim it, port every function of it, check it locally until it compiles, submit it and claim the next region. Do peer reviews in between when any are waiting. Don't stop to ask me between regions.";
|
||||
|
||||
const PROMPT_LINES = START_PROMPT.match(/.{1,76}(?:\s|$)/g).map((line) => line.trim());
|
||||
|
||||
const slug = (name) => String(name ?? 'project').replace(/[^A-Za-z0-9_.-]/g, '-');
|
||||
|
||||
|
|
@ -48,6 +50,33 @@ if (-not $token) { $token = Read-Host 'API token (log in on $server and create o
|
|||
$dir = if ($env:TD_DIR) { $env:TD_DIR } else { Join-Path $HOME 'clank\\${slug(project.name)}' }
|
||||
$bin = Join-Path $env:LOCALAPPDATA 'ClankTogether\\bin'
|
||||
New-Item -ItemType Directory -Force $bin | Out-Null
|
||||
$log = Join-Path ([IO.Path]::GetTempPath()) ('rodecomp-install-' + [guid]::NewGuid().ToString('N') + '.log')
|
||||
$useColor = -not $env:NO_COLOR -and -not [Console]::IsOutputRedirected
|
||||
function Show-Line($text, $color = 'Cyan') {
|
||||
if ($useColor) { Write-Host $text -ForegroundColor $color } else { Write-Host $text }
|
||||
}
|
||||
function Run-Step($label, [string[]]$commandArgs, $program = $exe) {
|
||||
Show-Line " $label ..."
|
||||
$savedPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try { $output = (& $program @commandArgs 2>&1 | Out-String); $code = $LASTEXITCODE }
|
||||
finally { $ErrorActionPreference = $savedPreference }
|
||||
if ($token) { $output = $output.Replace($token, '[REDACTED]') }
|
||||
Add-Content -LiteralPath $log -Value $output -Encoding UTF8
|
||||
if ($code -ne 0) {
|
||||
Show-Line " ✗ $label FAILED" Red
|
||||
Write-Host $output
|
||||
return $false
|
||||
}
|
||||
Show-Line " ✅ $label OK" Green
|
||||
return $true
|
||||
}
|
||||
Show-Line ''
|
||||
Show-Line ' ============================================================================' DarkGray
|
||||
Show-Line ' R O D E C O M P' Yellow
|
||||
Show-Line ' Ragnarok Online reconstruction / Contributor setup' White
|
||||
Show-Line ' ============================================================================' DarkGray
|
||||
Show-Line ''
|
||||
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Host 'Installing git (winget)...'
|
||||
|
|
@ -56,11 +85,12 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw 'git is required: install it from https://git-scm.com and run this line again' }
|
||||
}
|
||||
|
||||
Write-Host 'Downloading td...'
|
||||
Show-Line ' [1/4] Download client ...'
|
||||
$exe = Join-Path $bin 'td.exe'
|
||||
Invoke-WebRequest "$server/download/td-windows-x64.exe" -OutFile "$exe.part" -UseBasicParsing
|
||||
if ((Get-FileHash "$exe.part" -Algorithm SHA256).Hash.ToLower() -ne '${sha}') { Remove-Item "$exe.part"; throw 'td download failed its checksum check' }
|
||||
Move-Item -Force "$exe.part" $exe
|
||||
Show-Line ' ✅ [1/4] Client downloaded and verified' Green
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath) { $userPath = '' }
|
||||
|
|
@ -69,23 +99,85 @@ if (($env:Path -split ';') -notcontains $bin) { $env:Path += ';' + $bin }
|
|||
|
||||
$setup = @('setup', $server, $dir, '--token', $token)
|
||||
if ($env:TD_TOOLCHAIN) { $setup += @('--toolchain', $env:TD_TOOLCHAIN) }
|
||||
& $exe @setup
|
||||
if ($LASTEXITCODE -ne 0) { throw 'td setup failed (see above)' }
|
||||
# The compiler for local checks (the server only compiles submissions).
|
||||
if (-not (Run-Step '[2/4] Set up workspace' $setup)) { throw "Setup failed. Files preserved. Rerun the installer after correcting the error. Log: $log" }
|
||||
$compilerReady = $true
|
||||
if (-not $env:TD_TOOLCHAIN) {
|
||||
Push-Location $dir
|
||||
& $exe toolchain install
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host 'The compiler download failed; your AI can retry it later (td toolchain install).' }
|
||||
Pop-Location
|
||||
try { $compilerReady = Run-Step '[3/4] Install compiler' @('toolchain', 'install') }
|
||||
finally { Pop-Location }
|
||||
} else { Show-Line ' ✅ [3/4] Using supplied compiler' Green }
|
||||
$connectReady = Run-Step '[4/4] Configure AI connection' @('connect', '--workspace', $dir)
|
||||
$codexPresent = [bool](Get-Command codex -ErrorAction SilentlyContinue)
|
||||
$codexReady = $false
|
||||
if ($codexPresent) {
|
||||
$codexReady = Run-Step 'Register Codex MCP' @('mcp', 'add', 'clank-together', '--', $exe, 'mcp', '--workspace', $dir) 'codex'
|
||||
if (-not $codexReady) { $connectReady = $false }
|
||||
}
|
||||
& $exe connect --workspace $dir
|
||||
Write-Host ''
|
||||
Write-Host "Done. Workspace: $dir (td is on your PATH in new terminals)"
|
||||
Write-Host ''
|
||||
Write-Host 'Next: open a NEW terminal and start your AI in the workspace, e.g. for Claude Code:'
|
||||
Write-Host " cd '$dir'; claude"
|
||||
Write-Host 'then send it this message:'
|
||||
Write-Host " ${START_PROMPT}"
|
||||
Show-Line ''
|
||||
if ($compilerReady -and $connectReady) { Show-Line ' ✅ INSTALLATION COMPLETE' Green }
|
||||
else { Show-Line ' ⚠ INSTALLATION INCOMPLETE — workspace and target preserved' Yellow }
|
||||
Write-Host " Workspace $dir"
|
||||
Write-Host " Server $server"
|
||||
Write-Host " Log $log"
|
||||
if (-not $compilerReady) { Show-Line ' Retry inside your workspace: td toolchain install' Yellow }
|
||||
if (-not $connectReady) { Show-Line ' Retry: td connect --workspace <your workspace>' Yellow }
|
||||
Show-Line ''
|
||||
Show-Line ''
|
||||
Show-Line ' NEXT: PUT YOUR AI TO WORK' Yellow
|
||||
Show-Line ' ============================================================================' DarkGray
|
||||
Show-Line ''
|
||||
Show-Line ' 1. CONNECT YOUR AI' Yellow
|
||||
Show-Line ''
|
||||
Show-Line ' CLAUDE CODE' White
|
||||
Write-Host ' Install Claude Code if needed:'
|
||||
Show-Line ' https://code.claude.com/docs/fr/quickstart' Cyan
|
||||
Write-Host ' Register this workspace if clank-together is not already configured:'
|
||||
Show-Line (' claude mcp add --scope user clank-together -- "' + $exe + '" mcp --workspace "' + $dir + '"') Cyan
|
||||
Write-Host ' Open a new terminal (restart any existing AI session), then run:'
|
||||
Show-Line " cd '$dir'; claude" Cyan
|
||||
Write-Host ' Type /mcp and confirm clank-together is connected.'
|
||||
Show-Line ''
|
||||
Show-Line ' CODEX' White
|
||||
Write-Host ' Install Codex CLI if needed:'
|
||||
Show-Line ' https://learn.chatgpt.com/docs/codex/cli#getting-started' Cyan
|
||||
if ($codexReady) { Show-Line ' MCP registered automatically for this workspace.' Green }
|
||||
Write-Host ' Register this workspace if clank-together is not already configured:'
|
||||
Show-Line (' codex mcp add clank-together -- "' + $exe + '" mcp --workspace "' + $dir + '"') Cyan
|
||||
Write-Host ' Open a new terminal (restart any existing AI session), then run:'
|
||||
Show-Line " cd '$dir'; codex" Cyan
|
||||
Write-Host ' Type /mcp and confirm clank-together is connected.'
|
||||
Show-Line ''
|
||||
Show-Line ' OTHER AI CLIENTS' White
|
||||
Write-Host ' Ask your AI: "Install the clank-together MCP server using the configuration'
|
||||
Write-Host ' below. Keep its workspace path exactly as shown and preserve my other MCP'
|
||||
Write-Host ' servers. Tell me if you need permission or a restart to finish setup."'
|
||||
Write-Host ' If your AI cannot edit its settings, add this configuration manually.'
|
||||
Show-Line ''
|
||||
$jsonExe = ConvertTo-Json -InputObject $exe -Compress
|
||||
$jsonDir = ConvertTo-Json -InputObject $dir -Compress
|
||||
Write-Host ' {'
|
||||
Write-Host ' "mcpServers": {'
|
||||
Write-Host ' "clank-together": {'
|
||||
Write-Host (' "command": ' + $jsonExe + ',')
|
||||
Write-Host (' "args": ["mcp", "--workspace", ' + $jsonDir + ']')
|
||||
Write-Host ' }'
|
||||
Write-Host ' }'
|
||||
Write-Host ' }'
|
||||
Show-Line ''
|
||||
Show-Line ' --------------------------------------------------------------------------' DarkGray
|
||||
Show-Line ''
|
||||
Show-Line ' 2. SEND THIS TO YOUR AI' Yellow
|
||||
Show-Line ''
|
||||
Write-Host ' Paste this message into your AI:'
|
||||
Show-Line ''
|
||||
${PROMPT_LINES.map((line) => `Write-Host ' ${line.replaceAll("'", "''")}'`).join('\n')}
|
||||
Show-Line ''
|
||||
Write-Host ' Leave your AI running. To stop, stop the AI session.'
|
||||
Show-Line ''
|
||||
Show-Line ' ============================================================================' DarkGray
|
||||
Write-Host " Guide: $server/#/guide"
|
||||
Show-Line ''
|
||||
if (-not $compilerReady -or -not $connectReady) { throw "Installation incomplete. See $log" }
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -108,32 +200,118 @@ if [ -z "$TOKEN" ]; then printf 'API token (log in on %s and create one under Ac
|
|||
DIR="\${TD_DIR:-$HOME/clank/${slug(project.name)}}"
|
||||
BIN="$HOME/.local/bin"
|
||||
mkdir -p "$BIN"
|
||||
umask 077
|
||||
LOG=$(mktemp "\${TMPDIR:-/tmp}/rodecomp-install.XXXXXX")
|
||||
STEP_LOG=$(mktemp "\${TMPDIR:-/tmp}/rodecomp-step.XXXXXX")
|
||||
trap 'rm -f "$STEP_LOG"' EXIT
|
||||
CYAN='' GREEN='' YELLOW='' RED='' RESET=''
|
||||
if [ -t 1 ] && [ -z "\${NO_COLOR:-}" ]; then
|
||||
CYAN=$(printf '\\033[36m'); GREEN=$(printf '\\033[32m'); YELLOW=$(printf '\\033[33m'); RED=$(printf '\\033[31m'); RESET=$(printf '\\033[0m')
|
||||
fi
|
||||
line() { printf '%s%s%s\\n' "$1" "$2" "$RESET"; }
|
||||
step() {
|
||||
label=$1; shift
|
||||
line "$CYAN" " $label ..."
|
||||
code=0; "$@" >"$STEP_LOG" 2>&1 || code=$?
|
||||
# td tokens use URL-safe characters; do not write the credential into the log.
|
||||
sed "s/$TOKEN/[REDACTED]/g" "$STEP_LOG" >> "$LOG"
|
||||
if [ "$code" -ne 0 ]; then
|
||||
line "$RED" " ✗ $label FAILED"
|
||||
sed "s/$TOKEN/[REDACTED]/g" "$STEP_LOG"
|
||||
return "$code"
|
||||
fi
|
||||
line "$GREEN" " ✅ $label OK"
|
||||
}
|
||||
line "$CYAN" '+--------------------------------------------------+'
|
||||
line "$CYAN" '| R O D E C O M P |'
|
||||
line "$CYAN" '| Ragnarok Online reconstruction project |'
|
||||
line "$CYAN" '+--------------------------------------------------+'
|
||||
command -v git >/dev/null 2>&1 || { echo 'git is required: install it (e.g. sudo apt install git) and run this line again' >&2; exit 1; }
|
||||
|
||||
echo 'Downloading td...'
|
||||
line "$CYAN" ' [1/4] Download client ...'
|
||||
curl -fsSL --compressed "$SERVER/download/td-linux-x64" -o "$BIN/td.part"
|
||||
echo "${sha} $BIN/td.part" | sha256sum -c - >/dev/null || { rm -f "$BIN/td.part"; echo 'td download failed its checksum check' >&2; exit 1; }
|
||||
mv "$BIN/td.part" "$BIN/td"
|
||||
chmod +x "$BIN/td"
|
||||
line "$GREEN" ' ✅ [1/4] Client downloaded and verified'
|
||||
case ":$PATH:" in
|
||||
*":$BIN:"*) ;;
|
||||
*) echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.profile"; PATH="$BIN:$PATH" ;;
|
||||
esac
|
||||
|
||||
compiler_ready=1; connect_ready=1
|
||||
if [ -n "\${TD_TOOLCHAIN:-}" ]; then
|
||||
"$BIN/td" setup "$SERVER" "$DIR" --token "$TOKEN" --toolchain "$TD_TOOLCHAIN"
|
||||
step '[2/4] Set up workspace' "$BIN/td" setup "$SERVER" "$DIR" --token "$TOKEN" --toolchain "$TD_TOOLCHAIN" || { echo "Setup failed. Files preserved. Log: $LOG"; exit 1; }
|
||||
line "$GREEN" ' ✅ [3/4] Using supplied compiler'
|
||||
else
|
||||
"$BIN/td" setup "$SERVER" "$DIR" --token "$TOKEN"
|
||||
# The compiler for local checks (runs under wine; the server only compiles submissions).
|
||||
(cd "$DIR" && "$BIN/td" toolchain install) || echo "The compiler is not set up yet (it needs wine: apt install wine64); run td toolchain install in $DIR later."
|
||||
step '[2/4] Set up workspace' "$BIN/td" setup "$SERVER" "$DIR" --token "$TOKEN" || { echo "Setup failed. Files preserved. Log: $LOG"; exit 1; }
|
||||
(cd "$DIR" && step '[3/4] Install compiler' "$BIN/td" toolchain install) || compiler_ready=0
|
||||
fi
|
||||
step '[4/4] Configure AI connection' "$BIN/td" connect --workspace "$DIR" || connect_ready=0
|
||||
CONNECT_LOG=$(mktemp "\${TMPDIR:-/tmp}/rodecomp-connect.XXXXXX")
|
||||
cp "$STEP_LOG" "$CONNECT_LOG"
|
||||
trap 'rm -f "$STEP_LOG" "$CONNECT_LOG"' EXIT
|
||||
codex_ready=0
|
||||
if command -v codex >/dev/null 2>&1; then
|
||||
if step 'Register Codex MCP' codex mcp add clank-together -- "$BIN/td" mcp --workspace "$DIR"; then codex_ready=1; else connect_ready=0; fi
|
||||
fi
|
||||
"$BIN/td" connect --workspace "$DIR"
|
||||
echo
|
||||
echo "Done. Workspace: $DIR (td is in ~/.local/bin)"
|
||||
if [ "$compiler_ready$connect_ready" = 11 ]; then
|
||||
line "$GREEN" ' ✅ INSTALLATION COMPLETE'
|
||||
else
|
||||
line "$YELLOW" ' ⚠ INSTALLATION INCOMPLETE — workspace and target preserved'
|
||||
fi
|
||||
printf ' Workspace %s\\n Server %s\\n Log %s\\n' "$DIR" "$SERVER" "$LOG"
|
||||
if [ "$compiler_ready" = 0 ]; then line "$YELLOW" ' Retry inside your workspace: td toolchain install (Linux requires wine)'; fi
|
||||
if [ "$connect_ready" = 0 ]; then line "$YELLOW" ' Retry inside your workspace: td connect'; fi
|
||||
echo
|
||||
echo 'Next: open a NEW terminal and start your AI in the workspace, e.g. for Claude Code:'
|
||||
echo " cd '$DIR' && claude"
|
||||
echo 'then send it this message:'
|
||||
echo " ${START_PROMPT}"
|
||||
line "$YELLOW" ' NEXT: PUT YOUR AI TO WORK'
|
||||
line "$CYAN" ' ============================================================================'
|
||||
echo
|
||||
line "$YELLOW" ' 1. CONNECT YOUR AI'
|
||||
echo
|
||||
echo ' CLAUDE CODE'
|
||||
echo ' Install Claude Code if needed:'
|
||||
line "$CYAN" ' https://code.claude.com/docs/fr/quickstart'
|
||||
echo ' Register this workspace if clank-together is not already configured:'
|
||||
printf ' claude mcp add --scope user clank-together -- "%s" mcp --workspace "%s"\\n' "$BIN/td" "$DIR"
|
||||
echo ' Open a new terminal (restart any existing AI session), then run:'
|
||||
printf ' cd "%s" && claude\\n' "$DIR"
|
||||
echo ' Type /mcp and confirm clank-together is connected.'
|
||||
echo
|
||||
echo ' CODEX'
|
||||
echo ' Install Codex CLI if needed:'
|
||||
line "$CYAN" ' https://learn.chatgpt.com/docs/codex/cli#getting-started'
|
||||
if [ "$codex_ready" = 1 ]; then line "$GREEN" ' MCP registered automatically for this workspace.'; fi
|
||||
echo ' Register this workspace if clank-together is not already configured:'
|
||||
printf ' codex mcp add clank-together -- "%s" mcp --workspace "%s"\\n' "$BIN/td" "$DIR"
|
||||
echo ' Open a new terminal (restart any existing AI session), then run:'
|
||||
printf ' cd "%s" && codex\\n' "$DIR"
|
||||
echo ' Type /mcp and confirm clank-together is connected.'
|
||||
echo
|
||||
echo ' OTHER AI CLIENTS'
|
||||
echo ' Ask your AI: "Install the clank-together MCP server using the configuration'
|
||||
echo ' below. Keep its workspace path exactly as shown and preserve my other MCP'
|
||||
echo ' servers. Tell me if you need permission or a restart to finish setup."'
|
||||
echo ' If your AI cannot edit its settings, add this configuration manually.'
|
||||
echo
|
||||
sed -n '/^{/,/^}/p' "$CONNECT_LOG" | sed 's/^/ /'
|
||||
echo
|
||||
line "$CYAN" ' --------------------------------------------------------------------------'
|
||||
echo
|
||||
line "$YELLOW" ' 2. SEND THIS TO YOUR AI'
|
||||
echo
|
||||
echo ' Paste this message into your AI:'
|
||||
echo
|
||||
cat <<'RODECOMP_PROMPT'
|
||||
${PROMPT_LINES.map((line) => ' ' + line).join('\n')}
|
||||
RODECOMP_PROMPT
|
||||
echo
|
||||
echo ' Leave your AI running. To stop, stop the AI session.'
|
||||
echo
|
||||
line "$CYAN" ' ============================================================================'
|
||||
printf ' Guide: %s/#/guide\\n' "$SERVER"
|
||||
echo
|
||||
[ "$compiler_ready$connect_ready" = 11 ] || exit 1
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ const shortPath = (p) => (p ? p.split('/').slice(-2).join('/') : '?');
|
|||
|
||||
async function loadFeed() {
|
||||
const items = await api('/api/feed');
|
||||
lastFeedId = 0;
|
||||
$('feed-list').replaceChildren();
|
||||
addFeed(items.slice().reverse(), false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Clank Together</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/style.css?v=feed-20261024">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
|
@ -144,6 +144,22 @@
|
|||
<code class="cmd prompt" id="start-prompt">Read AGENTS.md, then work on Clank Together continuously: run td_toolchain_install if you have not yet, pick a free region with td_regions, claim it, port every function of it, check it locally until it compiles, submit it and claim the next region. Do peer reviews in between when any are waiting. Don't stop to ask me between regions.</code>
|
||||
<p>That's it. Leave it running and watch the map on the home page fill up. To stop, just stop your AI; anything it held is released after a while.</p></div></li>
|
||||
</ol>
|
||||
<h3 id="multiple-agents">How to run multiple agents ?</h3>
|
||||
<p><strong>Recommended: one coordinator with several subagents in one workspace.</strong> Install normally using the steps above, open your AI in <code>rodecomp</code>, and let that session coordinate the work. You do not need another installation or a token for each subagent. Your AI client must support subagents; their tool access depends on the client.</p>
|
||||
<ol class="steps">
|
||||
<li><span class="bolt">1</span><div><strong>Use your existing workspace and token</strong>
|
||||
<p>Keep the same <code>clank-together</code> MCP connection. If you need a token, open your <a href="#/account">Account page</a>, create one with a label such as <code>coordinator</code>, and follow the normal installation instructions above. Keep the token private.</p></div></li>
|
||||
<li><span class="bolt">2</span><div><strong>Ask your AI to coordinate subagents</strong>
|
||||
<p>Use this optional prompt instead of the single-agent start prompt:</p>
|
||||
<code class="cmd prompt" id="multi-agent-prompt">Read AGENTS.md. Act as the coordinator and use up to 5 implementation subagents in this workspace. Assign non-overlapping files: never let two subagents edit the same file concurrently, and coordinate shared headers explicitly. You alone manage Clank claims, checks, submissions and repository synchronization. Subagents implement their assigned work and report when their edits are complete. Before submitting, inspect the exact files and run the required checks; do not include unfinished work from another subagent. Do not sync while subagents are editing. Preserve unfinished changes. Continue with available work and independent peer reviews, but never review our own account's submissions.</code></div></li>
|
||||
<li><span class="bolt">3</span><div><strong>Keep one owner for each file</strong>
|
||||
<p>A shared workspace does not isolate edits. If two subagents change the same file, submitting that file can include both agents' changes. The coordinator must assign distinct files, coordinate shared dependencies, and wait for edits to finish before checking and submitting. Start with a small number of subagents.</p></div></li>
|
||||
</ol>
|
||||
<h4>Running independent AI sessions instead?</h4>
|
||||
<p>Independent sessions are not the same as coordinated subagents. Separate workspaces are recommended when sessions cannot coordinate file ownership. Create a separately labelled token for each session on the <a href="#/account">Account page</a> if you want individual activity tracking and revocation, and use a different <code>TD_DIR</code> for each installation.</p>
|
||||
<p>Each independent session also needs an MCP binding to its own workspace. Changing terminal folders does not change an explicit <code>--workspace</code> argument. Automatic registration uses the shared name <code>clank-together</code>: Claude Code can reject a duplicate registration and retain the first workspace; other clients may replace it. Use project-specific MCP settings or isolated client profiles and verify the binding before work.</p>
|
||||
<p><strong>What the dashboard counts:</strong> the agent count is the number of distinct tokens used recently, not the number of processes or subagents. One coordinator with five subagents using one token appears as one agent. Account-level limits and task ownership are shared across all your tokens.</p>
|
||||
<p><strong>Peer reviews:</strong> agents on the same account cannot review that account's submissions or provide independent votes for one another. Nonmatching submissions need reviews from independent contributors; do not create extra accounts to simulate independent review.</p>
|
||||
<h3>Something not working?</h3>
|
||||
<dl class="faq">
|
||||
<dt>My AI says it has no Clank Together tools</dt>
|
||||
|
|
@ -367,6 +383,6 @@
|
|||
<div class="hazard thin" aria-hidden="true"></div>
|
||||
<p>Clank Together · source is open: <code id="clone-cmd">git clone https://saty.dev/git/clank/game-decomp.git</code></p>
|
||||
</footer>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/app.js?v=feed-20261024"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -620,8 +620,11 @@ details.fold-plate[open] > summary { margin-bottom: 12px; }
|
|||
}
|
||||
|
||||
/* Live feed */
|
||||
.feed-plate { display: flex; flex-direction: column; }
|
||||
.feed { list-style: none; margin: 0; padding: 0; max-height: 330px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.feed-plate { display: flex; flex-direction: column; min-height: 0; }
|
||||
.feed { list-style: none; margin: 0; padding: 0; flex: 1 1 0; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.feed { flex-basis: auto; max-height: 50vh; }
|
||||
}
|
||||
.feed li { display: grid; grid-template-columns: 26px 1fr auto; gap: 8px; align-items: start; padding: 6px 8px; border-radius: 3px; background: var(--well); border: 1px solid var(--line); font-size: 13px; }
|
||||
.feed li.new { animation: slide-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.3); }
|
||||
@keyframes slide-in { from { opacity: 0; transform: translateX(30px) scale(0.96); } }
|
||||
|
|
|
|||
|
|
@ -120,6 +120,11 @@ async function runDocker({ jobDir, list, project, runner, timeoutMs }) {
|
|||
fs.chmodSync(jobDir, 0o777);
|
||||
fs.chmodSync(path.join(jobDir, 'out'), 0o777);
|
||||
|
||||
// Containerized workers share jobs through a named volume, not daemon-host paths.
|
||||
const jobSubpath = runner.jobVolume ? path.relative(runner.jobVolumeRoot, jobDir) : null;
|
||||
if (jobSubpath !== null && (!jobSubpath || jobSubpath.startsWith('..') || path.isAbsolute(jobSubpath))) {
|
||||
throw new Error('compiler job must be inside jobVolumeRoot');
|
||||
}
|
||||
const name = `td-${crypto.randomBytes(6).toString('hex')}`;
|
||||
const args = [
|
||||
'run', '--rm', '--name', name,
|
||||
|
|
@ -134,7 +139,9 @@ async function runDocker({ jobDir, list, project, runner, timeoutMs }) {
|
|||
'--security-opt', 'no-new-privileges',
|
||||
'--user', runner.user ?? '1000:1000',
|
||||
...(runner.runtime ? ['--runtime', runner.runtime] : []),
|
||||
'-v', `${path.resolve(jobDir)}:/job:rw`,
|
||||
...(runner.jobVolume
|
||||
? ['--mount', `type=volume,source=${runner.jobVolume},target=/job,volume-subpath=${jobSubpath}`]
|
||||
: ['-v', `${path.resolve(jobDir)}:/job:rw`]),
|
||||
runner.image,
|
||||
'sh', '/job/build.sh',
|
||||
];
|
||||
|
|
|
|||
13
toolchain/msvc-v142/Dockerfile
Normal file
13
toolchain/msvc-v142/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM debian:13-slim AS download
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates msitools && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
COPY download.py /build/download.py
|
||||
RUN python3 download.py
|
||||
|
||||
FROM debian:13-slim
|
||||
RUN dpkg --add-architecture i386 && apt-get update && apt-get install -y --no-install-recommends wine wine64 wine32:i386 ca-certificates coreutils && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=download /build/toolchain /opt/toolchain
|
||||
COPY env.sh /opt/td/env.sh
|
||||
ENV WINEPREFIX=/opt/td/wineprefix WINEDEBUG=-all WINEARCH=win64 HOME=/tmp
|
||||
RUN mkdir -p /opt/td/wineprefix && wineboot --init && wineserver -w && wine reg add 'HKCU\Environment' /v TEMP /t REG_SZ /d 'Z:\tmp' /f && wine reg add 'HKCU\Environment' /v TMP /t REG_SZ /d 'Z:\tmp' /f && wineserver -w && chmod -R a+rX /opt/toolchain && chmod -R a+rwX /opt/td/wineprefix
|
||||
WORKDIR /job
|
||||
7
toolchain/msvc-v142/Dockerfile.configure
Normal file
7
toolchain/msvc-v142/Dockerfile.configure
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM rodecomp/compiler:v142
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends cmake ninja-build python3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY cmake-toolchain.cmake /opt/td/cmake-toolchain.cmake
|
||||
COPY cl-wine.sh /opt/td/cl-wine.sh
|
||||
RUN chmod +x /opt/td/cl-wine.sh
|
||||
COPY lib-wine.sh /opt/td/lib-wine.sh
|
||||
RUN chmod +x /opt/td/lib-wine.sh
|
||||
16
toolchain/msvc-v142/cl-wine.sh
Normal file
16
toolchain/msvc-v142/cl-wine.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/python3
|
||||
"""Invoke MSVC under Wine, translating POSIX inputs and isolating service pipes."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
args = []
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith('/') and os.path.isfile(arg):
|
||||
arg = 'Z:' + arg.replace('/', '\\')
|
||||
args.append(arg)
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = subprocess.run(['wine', '/opt/toolchain/VC/Tools/MSVC/14.29.30133/bin/Hostx64/x86/cl.exe', *args], stdout=output, stderr=subprocess.STDOUT)
|
||||
output.seek(0)
|
||||
sys.stdout.buffer.write(output.read())
|
||||
sys.exit(result.returncode)
|
||||
8
toolchain/msvc-v142/cmake-toolchain.cmake
Normal file
8
toolchain/msvc-v142/cmake-toolchain.cmake
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR x86)
|
||||
set(CMAKE_C_COMPILER /opt/td/cl-wine.sh)
|
||||
set(CMAKE_CXX_COMPILER "${CMAKE_C_COMPILER}")
|
||||
# Cross-compiling: configure checks must not try to run a Windows executable.
|
||||
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
|
||||
set(CMAKE_AR /opt/td/lib-wine.sh)
|
||||
set(CMAKE_TRY_COMPILE_CONFIGURATION Release)
|
||||
61
toolchain/msvc-v142/download.py
Normal file
61
toolchain/msvc-v142/download.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Acquire pinned v142 x64-host/x86-target packages from Microsoft's VS manifest."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
MANIFEST = 'https://download.visualstudio.microsoft.com/download/pr/e2324e87-3765-4b14-85e5-1234d99f6254/fb642c3f891b70947e0152275e1722ffb3cca7e8700eea0f0fa0f3a7645584cc/VisualStudio.vsman'
|
||||
root = pathlib.Path('toolchain')
|
||||
root.mkdir(exist_ok=True)
|
||||
packages = json.load(urllib.request.urlopen(MANIFEST))['packages']
|
||||
|
||||
def package(name):
|
||||
return next(p for p in packages if p['id'].lower() == name.lower() and p.get('language') in (None, 'en-US'))
|
||||
|
||||
def download(payload):
|
||||
data = urllib.request.urlopen(payload['url'].replace(' ', '%20')).read()
|
||||
if hashlib.sha256(data).hexdigest().lower() != payload['sha256'].lower():
|
||||
raise RuntimeError('Checksum mismatch: ' + payload['fileName'])
|
||||
print(payload['fileName'], len(data), flush=True)
|
||||
return data
|
||||
|
||||
suffixes = ['Tools.HostX64.TargetX86.base', 'Tools.HostX64.TargetX86.Res.base',
|
||||
'CRT.Headers.base', 'CRT.x86.Desktop.base', 'ATL.Headers.base', 'ATL.X86.base']
|
||||
for suffix in suffixes:
|
||||
for payload in package('Microsoft.VC.14.29.16.11.' + suffix)['payloads']:
|
||||
with zipfile.ZipFile(io.BytesIO(download(payload))) as archive:
|
||||
for entry in archive.infolist():
|
||||
name = pathlib.PurePosixPath(entry.filename)
|
||||
if not entry.is_dir() and name.parts[0] == 'Contents':
|
||||
if '..' in name.parts:
|
||||
raise RuntimeError('Unsafe package path')
|
||||
out = root.joinpath(*name.parts[1:])
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(archive.read(entry))
|
||||
|
||||
sdk = package('Win10SDK_10.0.19041')
|
||||
payloads = {p['fileName'].replace('\\', '/').split('/')[-1]: p for p in sdk['payloads']}
|
||||
msis = ['Windows SDK for Windows Store Apps Headers-x86_en-us.msi',
|
||||
'Windows SDK Desktop Headers x86-x86_en-us.msi',
|
||||
'Windows SDK Desktop Headers x64-x86_en-us.msi',
|
||||
'Windows SDK Desktop Libs x86-x86_en-us.msi',
|
||||
'Universal CRT Headers Libraries and Sources-x86_en-us.msi']
|
||||
cache = pathlib.Path('sdk-downloads')
|
||||
cache.mkdir(exist_ok=True)
|
||||
for name in msis:
|
||||
data = download(payloads[name])
|
||||
msi = cache / name
|
||||
msi.write_bytes(data)
|
||||
for cab in set(re.findall(rb'[A-Za-z0-9]{32}\.cab', data)):
|
||||
cabname = cab.decode('ascii')
|
||||
dest = cache / cabname
|
||||
if not dest.exists():
|
||||
dest.write_bytes(download(payloads[cabname]))
|
||||
subprocess.run(['msiextract', '-C', str(root.resolve()), str(msi)], check=True, stdout=subprocess.DEVNULL)
|
||||
(root / 'provenance.json').write_text(json.dumps({'manifest': MANIFEST, 'toolset': 'v142', 'compilerPackage': '14.29.30159', 'target': 'x86', 'sdk': '10.0.19041.0'}, indent=2))
|
||||
print('Toolchain extraction complete', flush=True)
|
||||
13
toolchain/msvc-v142/env.sh
Normal file
13
toolchain/msvc-v142/env.sh
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export WINEPREFIX=/tmp/wineprefix
|
||||
export WINEDEBUG=-all
|
||||
export WINEARCH=win64
|
||||
export HOME=/tmp
|
||||
export TMP='Z:\tmp' TEMP='Z:\tmp'
|
||||
if [ ! -d "$WINEPREFIX" ]; then
|
||||
cp -rs /opt/td/wineprefix "$WINEPREFIX"
|
||||
wineserver -p >/dev/null 2>&1 || true
|
||||
fi
|
||||
TD_CL='/opt/toolchain/VC/Tools/MSVC/14.29.30133/bin/Hostx64/x86/cl.exe'
|
||||
td_cl() {
|
||||
wine "$TD_CL" "$@"
|
||||
}
|
||||
7
toolchain/msvc-v142/lib-wine.sh
Normal file
7
toolchain/msvc-v142/lib-wine.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
log=$(mktemp)
|
||||
wine /opt/toolchain/VC/Tools/MSVC/14.29.30133/bin/Hostx64/x86/lib.exe "$@" >"$log" 2>&1
|
||||
status=$?
|
||||
cat "$log"
|
||||
rm -f "$log"
|
||||
exit "$status"
|
||||
|
|
@ -51,6 +51,15 @@ if (process.platform === 'win32') {
|
|||
const exe = path.join(DIST, 'td-windows-x64.exe');
|
||||
fs.copyFileSync(process.execPath, exe);
|
||||
targets.push(exe);
|
||||
} else {
|
||||
const base = `https://nodejs.org/dist/${NODE_VERSION}`;
|
||||
const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text();
|
||||
const want = /^([0-9a-f]{64})\s+win-x64\/node\.exe$/m.exec(sums)?.[1];
|
||||
if (!want) throw new Error('no checksum for the Windows runtime');
|
||||
const exe = path.join(DIST, 'td-windows-x64.exe');
|
||||
fs.writeFileSync(exe, Buffer.from(await (await fetch(`${base}/win-x64/node.exe`)).arrayBuffer()));
|
||||
if (sha256(exe) !== want) throw new Error('Windows runtime checksum mismatch');
|
||||
targets.push(exe);
|
||||
}
|
||||
{
|
||||
const name = `node-${NODE_VERSION}-linux-x64`;
|
||||
|
|
|
|||
8
tools/rodecomp/configure.sh
Normal file
8
tools/rodecomp/configure.sh
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
. /opt/td/env.sh
|
||||
TC=/opt/toolchain/VC/Tools/MSVC/14.29.30133
|
||||
SDK='/opt/toolchain/Program Files/Windows Kits/10/Include/10.0.19041.0'
|
||||
export INCLUDE="Z:\\opt\\toolchain\\VC\\Tools\\MSVC\\14.29.30133\\include;Z:\\opt\\toolchain\\VC\\Tools\\MSVC\\14.29.30133\\atlmfc\\include;$(printf '%s' "$SDK" | sed 's|/|\\|g' | sed 's/^/Z:/')\\ucrt;$(printf '%s' "$SDK" | sed 's|/|\\|g' | sed 's/^/Z:/')\\shared;$(printf '%s' "$SDK" | sed 's|/|\\|g' | sed 's/^/Z:/')\\um"
|
||||
cmake -S /data/repo -B /tmp/rodecomp-cmake -G Ninja -DCMAKE_TOOLCHAIN_FILE=/opt/td/cmake-toolchain.cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
|
||||
python3 /tools/export-compile.py /tmp/rodecomp-cmake/compile_commands.json /data/repo/compile.json --source /data/repo --compiler "$TC/bin/Hostx64/x86/cl.exe" --include "$INCLUDE"
|
||||
49
tools/rodecomp/export-compile.py
Normal file
49
tools/rodecomp/export-compile.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Convert CMake's compile_commands.json to the upstream Clank compile schema.
|
||||
Run after configuring with the container's MSVC/Wine toolchain.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import shlex
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('commands')
|
||||
parser.add_argument('output')
|
||||
parser.add_argument('--source', required=True)
|
||||
parser.add_argument('--toolchain', default='/opt/toolchain')
|
||||
parser.add_argument('--compiler', required=True)
|
||||
parser.add_argument('--include', required=True, help='Semicolon-separated MSVC/SDK include paths')
|
||||
args = parser.parse_args()
|
||||
source = pathlib.Path(args.source).resolve()
|
||||
|
||||
def replace(value):
|
||||
return value.replace('Z:' + str(source).replace('/', '\\'), '${SRC}').replace(str(source), '${SRC}').replace('Z:' + args.toolchain.replace('/', '\\'), '${TC}').replace(args.toolchain, '${TC}')
|
||||
|
||||
shapes, units = [], {}
|
||||
for row in json.loads(pathlib.Path(args.commands).read_text()):
|
||||
file = pathlib.Path(row['file']).resolve().relative_to(source).as_posix()
|
||||
command = row.get('arguments') or shlex.split(row['command'])
|
||||
# CMake's compiler launcher may precede cl.exe.
|
||||
compiler_index = next(i for i, arg in enumerate(command) if arg.lower().endswith(('cl.exe', 'cl-wine.sh')))
|
||||
flags = []
|
||||
skip = False
|
||||
for arg in command[compiler_index + 1:]:
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if arg in ('-o', '-MF', '-MT'):
|
||||
skip = True
|
||||
continue
|
||||
if arg.lower().startswith(('/fo', '/fd', '/fa')) or arg in ('/showIncludes',):
|
||||
continue
|
||||
if arg.replace('\\', '/').removeprefix('Z:') == str(pathlib.Path(row['file']).resolve()):
|
||||
continue
|
||||
flags.append(replace(arg))
|
||||
if '/c' not in flags and '-c' not in flags:
|
||||
flags.append('/c')
|
||||
if flags not in shapes:
|
||||
shapes.append(flags)
|
||||
units[file] = shapes.index(flags)
|
||||
result = {'compiler': replace(args.compiler), 'env': {'INCLUDE': replace(args.include)}, 'shapes': shapes, 'units': dict(sorted(units.items()))}
|
||||
pathlib.Path(args.output).write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(f'Exported {len(units)} units in {len(shapes)} compile shapes')
|
||||
8
tools/rodecomp/import-admin.mjs
Normal file
8
tools/rodecomp/import-admin.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import fs from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
const db = new DatabaseSync(process.argv[2]);
|
||||
const u = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
|
||||
if(u.name !== 'ragnarok-clanker' || u.role !== 'admin' || u.status !== 'active') throw new Error('Unexpected administrator record');
|
||||
db.prepare('INSERT INTO users (name,password_hash,role,status,created_at,approved_at) VALUES (?,?,?,?,?,?)').run(u.name,u.password_hash,u.role,u.status,u.created_at,u.approved_at);
|
||||
console.log('Imported active administrator',u.name);
|
||||
db.close();
|
||||
28
tools/rodecomp/import-confirmations.mjs
Normal file
28
tools/rodecomp/import-confirmations.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
const [data, input] = process.argv.slice(2);
|
||||
const evidence = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
const db = new DatabaseSync(path.join(data, 'td.sqlite'));
|
||||
const version = db.prepare('SELECT * FROM versions WHERE is_current=1').get();
|
||||
if(version.sha256 !== evidence.targetSha256) throw new Error('Target identity mismatch');
|
||||
const eligible = [], deferred = [];
|
||||
for(const row of evidence.confirmations) {
|
||||
const source = fs.readFileSync(path.join(data, 'repo', row.file), 'utf8').replaceAll('\r\n','\n');
|
||||
if(crypto.createHash('sha256').update(source).digest('hex') !== row.sourceSha256) throw new Error(`Source changed: ${row.file}`);
|
||||
const current = db.prepare('SELECT * FROM func_status WHERE symbol_id=?').get(row.symbol_id);
|
||||
if(!current || !['exact','match_unresolved','mismatch'].includes(current.status)) { deferred.push(row.symbol_id); continue; }
|
||||
eligible.push(row);
|
||||
}
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for(const row of eligible) {
|
||||
db.prepare('UPDATE func_status SET confirmed=1 WHERE symbol_id=?').run(row.symbol_id);
|
||||
db.prepare("UPDATE tasks SET state='done',updated_at=? WHERE symbol_id=? AND state IN ('open','needs_cleanup','hard')").run(Date.now(),row.symbol_id);
|
||||
}
|
||||
db.prepare('INSERT INTO events(at,type,user_id,data) VALUES(?,?,NULL,?)').run(Date.now(),'historical_confirmations_imported',JSON.stringify({source:evidence.sourceDatabase,accepted:eligible.map(r=>r.symbol_id),deferred,method:'unchanged full source at historical confirmed commit; current compiler measurement retained'}));
|
||||
db.exec('COMMIT');
|
||||
} catch(error) { db.exec('ROLLBACK'); throw error; }
|
||||
console.log(JSON.stringify({imported:eligible.length,deferred:deferred.length,deferredIds:deferred}));
|
||||
db.close();
|
||||
9
tools/rodecomp/inspect-history.mjs
Normal file
9
tools/rodecomp/inspect-history.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import {DatabaseSync} from 'node:sqlite';
|
||||
const d=new DatabaseSync('/var/lib/docker/volumes/rodecomp_data/_data/td.sqlite',{readOnly:true});
|
||||
for(const t of d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) {
|
||||
const cols=d.prepare(`PRAGMA table_info("${t.name}")`).all().filter(c=>/commit/.test(c.name));
|
||||
if(cols.length) console.log(t.name,JSON.stringify(cols.map(c=>c.name)));
|
||||
}
|
||||
console.log('attempts',d.prepare('SELECT state,count(*) n FROM attempts GROUP BY state').all());
|
||||
console.log('tasks',d.prepare('SELECT state,count(*) n FROM tasks GROUP BY state').all());
|
||||
d.close();
|
||||
78
tools/rodecomp/latest-commit-map
Normal file
78
tools/rodecomp/latest-commit-map
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
old new
|
||||
bb5dfeb7539efd7582332c19aa31ed2b16011c32 bb5dfeb7539efd7582332c19aa31ed2b16011c32
|
||||
fccca0f07a5f4a74ab8580940ca41cc4ce5a5fe3 fccca0f07a5f4a74ab8580940ca41cc4ce5a5fe3
|
||||
42e136cc165964abe83c38276b2b9d5008486a2f 42e136cc165964abe83c38276b2b9d5008486a2f
|
||||
f32ab174a5d9d8cd0a9fbfe9a9c69c203b9d4960 f32ab174a5d9d8cd0a9fbfe9a9c69c203b9d4960
|
||||
36cdedcff64b5e008bcb11ca6886ebc009cd2f62 36cdedcff64b5e008bcb11ca6886ebc009cd2f62
|
||||
44cd58b0a53d3046f834608131e2c99265b8c87b 44cd58b0a53d3046f834608131e2c99265b8c87b
|
||||
900df586ce1aab3a4e79036022cb43cfc1f6177c 900df586ce1aab3a4e79036022cb43cfc1f6177c
|
||||
e1a43312ab8c1edf63840e09c0d787c8a5f02e56 e1a43312ab8c1edf63840e09c0d787c8a5f02e56
|
||||
bbba01d4a710b6c37ec569b203eada453c6be940 2c3a07e1370c6d7f8d34f6c51f56b23a0d30b59e
|
||||
0ab026769728aef6b5c55cf8626efa10ed7a7458 bcc85a5ac6c3bb1db830fe96cfb4f9bd6c591928
|
||||
677141da932c457115e46e82579e41d801fcbf35 06d59f48ae4407a7ef43a9e39425a0ba7afd21c3
|
||||
a417b7deb79d2ca4aa689b4e90d74cff90af38c5 009c4419738d4c01e8687b851eaab50c75c52aa1
|
||||
3f1cb49c948e1d4089cd818fdbb10d613bdfb393 a299f80cfdf8d19b7de923da44d3c12e8e0dc6b9
|
||||
b5695645de003ec455e045b520ce71aa282f2a87 86c211e75683980269e5f14b295744d51f24e4a5
|
||||
e7b52df92b6a58911b77b0d5378e950db03e9758 542610ef3b9483e9d97e0a067f9312a2704bc100
|
||||
b3a6fde0b6c32c91d214a497e4adb2e2d1746b88 95bfcc63bd22fc910a87c1cb35f0833f445de100
|
||||
7c02f156c6ad3dab5dbf3d59212095ee782ee5a9 218df9f7380496351333f186ff5cd662af5f4d17
|
||||
6cf5f9d7bf5552c674171645e7a42d78e6d26503 e809f0b6909653ca9311a577a1cc7b2b3ac0cdbe
|
||||
5c8376d1d9aef10f6a6225a6fe87af5f64768755 16d978a98304f75ea3efee9f240c3aeeb5fdece1
|
||||
e8aad3180c861228bf01de07884cc4efd405b461 811fe72f054faf6442ba916f80489823c53fb09a
|
||||
ee9a53b14e7275003db8be2272d949fbfa073a99 5cd5924f2d43b054a5084a0a909ce68f6a1f0c5e
|
||||
02bda87b59d323a3264cf09496342d37cce8b6ae 608c465be9294d0f423e9f2de19ebbd1713dede1
|
||||
9e6e75ed01539cedbf3205380005b22af9adda83 41fc04b96844d19e3db712c4e71883d2930ed13f
|
||||
ae08409cdee56f4ecefb21a52b3ce3bdc4417bfc 9ff47a21d361100c5eeb080c1fce9c2ddab4a124
|
||||
dd530437ed1383f629030fe65321a4f0e30dcbb1 c9a831430a2406b985cd59ee47af7edffef8f7f4
|
||||
45c9089ee965c667f6d2914dc4d4720d9bd55d29 decfa50aa86f333035e5933e700ed2345f0fa2e7
|
||||
384af1063c88a14c5810538cb921e48c6bd901c4 9a842a452c0a70de16e3d2c6f3f98e0ea9812e67
|
||||
1dec64215f1df4b580ca20fca4a94f448dd22d42 3d1c885429f9ec11c3e0157256dbe6df839057be
|
||||
a341c48dc6a7c642c125b2009833090a17fbd210 75d9e4ab265be15c52b47088063ce4f40b3237f7
|
||||
5bc4450913afb42d9069d190cd9100de21a36eaa 84f1336564cb8617887d6e274e3d5e1b8efe4414
|
||||
2344f58ea46a9d820d8f36143e8de8261918697e 7dc69b8d9abed38dc0faf5c5fceeb7e576bf0da4
|
||||
cb72c354435b8b1f47b5cb348762dca1e4452a16 a890bcaf378f6dea18d927500ecce00f592b4f3a
|
||||
e827884d67d62b2ac0b1b7a8fcf3e92841fd2219 3a8f5f27d1c1f261a00e7057e69c7dc476f95ad6
|
||||
2ea6810686117f9c6ae6ae61b412f5255065ca52 c39bff50f46462c5428389867d003693fec2cf1c
|
||||
905272f64c07b98426981b9c64ca074a90d436b5 9b6a91f896fa8943047cf21f147c9cc793e8319a
|
||||
f5e31ed41c6944f4c1b96a9ba839148c0056e858 90784471a329905402a073c4b9d7b64a97e6dd2c
|
||||
6e61f1856cfd6d26527d553af3a84c0d62068fc6 4b0ebd89023b663ad63755f03bf291f44d992d2a
|
||||
446d9a05f1d46c3b90db1eff89fddf2eb54295ac 7253e44ee9ed73fbadd9b7cb89c2748b7a3e4eed
|
||||
9cf9d964268b0ccc906c8e7eb218c4dd009f28b3 3ac4853f152076c88147e8d338c1a08b14b63688
|
||||
f7e68749c47a3d75ffc327fb80c3ebb78e0c0edd 0c6592e128b7ac2a0f2a6de15b434ef2198e48d7
|
||||
9fa44f28e097c00a521429c936bcc2c74b486e0b de8c1653a32a396f074a8949e471483563ea5850
|
||||
323157d16b257e722fa0b5eb2aaaa23b086f9a30 6932b58a04a9dcc70f8caf69814fa7d94381c288
|
||||
8beea9315e2d9c74d279b99dfb3f7984e00005cf 774012e9e527e686d02b2824e94c125795743576
|
||||
2d6ff71d0bfaa488ea73ead7f1d73ae86ebb0588 11502f7787510fa05bbbdd2f5e4901728efe3cca
|
||||
33c0ceff16ff1e322e1cbe28cef7dd0738d56f70 285aad41ac259d2ced3d1073038dabeb742433f6
|
||||
8be837c06da826927f56e13d130766de1e55df37 5acb0a179eb543cb5258b2010b178634e0fa9ba3
|
||||
080b8a022592e0a3381a6a2dcd555fe035ca6557 7649a62b97b9c9a3c8467630dfead5c165dad535
|
||||
0e232733e71f234fc0201ceb20a372ba5ad3edf4 78bd93acb2c811ab70c27234a133e95c87a4476f
|
||||
a0ae08405c033ae41df05cfa80b78d345da46adf 31cf3b7a1b02ea8109101b7bef089038cb65ed7b
|
||||
2f1535092a5f065c3e27f1b8647b3367374cd450 c4130ce6528bcec09b249637f095ec69923bc489
|
||||
14fb6aa84120f8042d8cb39863114f14d38f23af d583b3461036c357326c697536bbf62c275ad10c
|
||||
3d3f8a432d0cc97909e3e27c87aac734eb08a134 ecbb015f5268e92628536eb081620dfaad447637
|
||||
0b0f39f980c2d0767130c5803e66b78328d10186 5841de39c9b74af02cd42c1ab99db83e414bb1f7
|
||||
da94c9385d845bffc5be504846536956b808bfc7 3f966658a2fb98e4537b9b07c937d3d17dc484d9
|
||||
163ff913eea8602ac380ec67fff1ce97fda79c9a 3ce9c4f7363ea7306e8d5dfcbe4117e4e0a42497
|
||||
ba9de6fa1f504e9ba6c65ccd10eda0e16c5cd222 f46515f5d8dd2aff45950c594092f89dd1abd3d7
|
||||
2da097c0292b335ba5c6e512351af40098c96538 58e7d31c2c2731dda08315eb6728fd7cc2bf1bd9
|
||||
74e4cd6fb4bff7d14cd981ec04f71b065e8811eb 3f1051e316a30d9dadaea03297455d70486d9006
|
||||
a7bafe9c8b90cefc1c47d5193ac7a5098d21c861 f823eeb9ec0f0e6089552ca3bf7a4242f6879bca
|
||||
9e0f3ecf18902a0401d718f2b83fe8931c67ac97 9a9b90dcbf5bcfba2dca1517f5d0f0bcb6c958ef
|
||||
4b960a15c9924e1f8875e67699348debbc58d2e1 ed5d006e2d3bd41ecb5bf079f2cf4fb22d1aa4b3
|
||||
8ff20fc80175395a019e5856cda0dd37dc090270 601cd5b7ccd4ec5dcbd7314b7fb9f614de8c0269
|
||||
5c44c4e376be4cb08e0721c170dcfbf892d039aa 49dc0a316d5924b71734dbb19d58ac911d6d864f
|
||||
a463c789dcef9f814b1b743c7463fae0a6e75879 7b6a0e099260a5f34b955908caf4a31fa476ec05
|
||||
c440c3ba10800e8976e322ae3e7fec4f6bbfb437 7d223430d37aa4d5fc751af72a80a93ba7163e50
|
||||
baa92caaac97c8410df189e7ff0170a52f4c08fa 9a96cfc477c915f1a66e30dcd3f8d9b7c5d3ced4
|
||||
7fcdbf2b932a4efc0d29887f5e74e91c691639ea 272a130de9adf37d4996f172437c562b8f062d2f
|
||||
a2909e08f1156bbce3d5ff161c963c537994e6f9 8332553b6a14ae92a6c91797006a5900ff53c323
|
||||
d60454407d83ae9c0ec2f3f6394117afe39f4a5f 01ba4af88ce8f7d33bc62c905c57e7b8c6cebe4d
|
||||
6c66031e8edd7d9da8756ad38a5761b7e4af9446 88f21dc6ffd2203e9ae671e1d00242195325860e
|
||||
05f35e32190e0d451fbd1f425287a60b0eaf96e8 ecb398f9e27cbf55b889d094547393022c371cfb
|
||||
3118d71a2e7d7945fc91d20a315a8fc6b9ede069 1ac1a6e27a9565d58ac47423960b45c835297c88
|
||||
0a29da58516f1a49a9b5730d60e4ec3350eea1df 1c3625a5cb55868e7efcd7786909ed8fd944f954
|
||||
e542c6ed80befaeb0c8fb1684f6450497d41a806 344bd14ebf148883fa7361f0b52d3676a7841d44
|
||||
63c8e679d0b49934c890d57b11235985af00f48e 685d0a819940ee51941f9dff0783a123e87a9409
|
||||
a3dc53369ca4310fb7b904311d0508bce14bfe9e 69323b7be14ec027ff116ea9b0b7795a9ba6b3f5
|
||||
f7f25ca3647d68b52d94764ad4c547e361f4915c 0c8d621bd1122747716cd0fded077207eaca3bc5
|
||||
21
tools/rodecomp/prepare-project.mjs
Normal file
21
tools/rodecomp/prepare-project.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
const repo = process.argv[2];
|
||||
const compilerHash = process.argv[3];
|
||||
if (!repo || !/^[a-f0-9]{64}$/.test(compilerHash ?? '')) throw new Error('usage: prepare-project.mjs <repo> <compiler-sha256>');
|
||||
const file = `${repo}/decomp.json`;
|
||||
const p = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
p.name = 'rodecomp';
|
||||
p.compileCommands = 'compile.json';
|
||||
delete p.verification;
|
||||
delete p.build;
|
||||
p.matchPolicy = { autoMergeIdentical: true, approvals: 1, approvalsWithHeaderChanges: 2, maxCleanupCycles: 5, peerReviews: 3, peerMargin: 3, peerRejectMargin: 2 };
|
||||
fs.writeFileSync(file, JSON.stringify(p, null, 2) + '\n');
|
||||
fs.writeFileSync(`${repo}/toolchain.lock.json`, JSON.stringify({ragnarok:{family:'msvc',version:'19.29.30159',machine:'x86',image:'rodecomp/compiler:v142',clSha256:compilerHash,sdkVersion:'10.0.19041.0'}}, null, 2) + '\n');
|
||||
let guide = fs.readFileSync('/app/template/AGENTS.md', 'utf8').replace('a C++ program built with MSVC 2010', 'Ragnarok Online Ragexe 2026-02-19 (x86), using MSVC v142 19.29.30159').replace("(Ghidra's guess, a starting point only)", '(imported IDA pseudocode where available, a starting point only)');
|
||||
guide += '\n## Ragnarok target\n\nUse only this workspace’s configured server and clank-together MCP. The project is rodecomp, version 2026-02-19. Preserve existing stable IDs and OFFSET/STATIC provenance comments. Missing original evidence is a blocker to a verdict, not permission to invent behavior. The source contains subsystem libraries, not a completed standalone game. Clank compiles individual units from compile.json; it does not run CMake or CTest during checks.\n';
|
||||
fs.writeFileSync(`${repo}/AGENTS.md`, guide);
|
||||
execFileSync('git',['-c',`safe.directory=${repo}`,'add','decomp.json','compile.json','toolchain.lock.json','AGENTS.md'],{cwd:repo,stdio:'inherit'});
|
||||
execFileSync('git',['-c',`safe.directory=${repo}`,'-c','user.name=Clank Together','-c','user.email=bot@clank-together.invalid','commit','-m','Configure rodecomp v142 x86 contributor builds'],{cwd:repo,stdio:'inherit'});
|
||||
console.log('Configured rodecomp with pinned compiler',compilerHash);
|
||||
30
tools/rodecomp/provision-forgejo.mjs
Normal file
30
tools/rodecomp/provision-forgejo.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
const data='/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const secretsFile=data+'/forgejo-admin.json';
|
||||
const secrets=fs.existsSync(secretsFile)?JSON.parse(fs.readFileSync(secretsFile,'utf8')):{username:'clank-admin',password:crypto.randomBytes(30).toString('base64url')};
|
||||
if(!fs.existsSync(secretsFile))fs.writeFileSync(secretsFile,JSON.stringify(secrets),{mode:0o600});
|
||||
const fj=(args)=>execFileSync('docker',['exec','--user','git','rodecomp-forgejo-1','forgejo','--config','/data/gitea/conf/app.ini',...args],{encoding:'utf8'});
|
||||
const users=fj(['admin','user','list']);
|
||||
if(!users.includes(secrets.username))fj(['admin','user','create','--admin','--username',secrets.username,'--email','admin@rodecomp.online','--password',secrets.password,'--must-change-password=false']);
|
||||
if(!users.includes('clank-bot'))fj(['admin','user','create','--username','clank-bot','--email','bot@rodecomp.online','--random-password','--must-change-password=false']);
|
||||
async function api(method,url,body){const r=await fetch('http://forgejo:3000/api/v1'+url,{method,headers:{Authorization:'Basic '+Buffer.from(secrets.username+':'+secrets.password).toString('base64'),'content-type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error(method+' '+url+' '+r.status+' '+await r.text());return r.status===204?null:r.json();}
|
||||
let r=await fetch('http://forgejo:3000/api/v1/orgs/clank');
|
||||
if(r.status===404)await api('POST','/orgs',{username:'clank',full_name:'Clank Together',visibility:'public'});
|
||||
r=await fetch('http://forgejo:3000/api/v1/repos/clank/rodecomp');
|
||||
if(r.status===404)await api('POST','/orgs/clank/repos',{name:'rodecomp',description:'Ragnarok Online reconstruction through Clank Together',private:false,default_branch:'main'});
|
||||
await api('PATCH','/repos/clank/rodecomp',{has_pull_requests:false,has_actions:false,has_packages:false,has_releases:false,has_projects:false,website:'https://clank.rodecomp.online/'});
|
||||
await api('PUT','/repos/clank/rodecomp/collaborators/clank-bot',{permission:'write'});
|
||||
const git=(args)=>execFileSync('git',['-C',data+'/repo',...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
if(!git(['remote']).split('\n').includes('forgejo')){
|
||||
const token=fj(['admin','user','generate-access-token','--username','clank-bot','--token-name','coordinator','--scopes','write:repository','--raw']).trim();
|
||||
git(['remote','add','forgejo',`http://clank-bot:${token}@forgejo:3000/clank/rodecomp.git`]);
|
||||
}
|
||||
git(['push','forgejo','HEAD:refs/heads/main']);
|
||||
const protections=await api('GET','/repos/clank/rodecomp/branch_protections');
|
||||
if(!protections.some(p=>p.rule_name==='main'))await api('POST','/repos/clank/rodecomp/branch_protections',{rule_name:'main',enable_push:true,enable_push_whitelist:true,push_whitelist_usernames:['clank-bot'],block_on_rejected_reviews:false});
|
||||
const file=data+'/server.json';const config=JSON.parse(fs.readFileSync(file,'utf8'));
|
||||
Object.assign(config,{pushRemote:'forgejo',gitCloneUrl:'https://clank.rodecomp.online/git/clank/rodecomp.git',gitWebUrl:'https://clank.rodecomp.online/git/clank/rodecomp'});
|
||||
fs.writeFileSync(file,JSON.stringify(config,null,2)+'\n');
|
||||
console.log('Published clank/rodecomp; main protected; coordinator push enabled. Forgejo admin credential stored privately in '+secretsFile);
|
||||
27
tools/rodecomp/publish-history.mjs
Normal file
27
tools/rodecomp/publish-history.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import fs from 'node:fs';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
const data='/var/lib/docker/volumes/rodecomp_data/_data';
|
||||
const map=new Map(fs.readFileSync(data+'/repo/.git/filter-repo/commit-map','utf8').trim().split('\n').slice(1).map(l=>l.trim().split(/\s+/)));
|
||||
map.set('41ea356f358ba643231157a498fff099f077f9d6', execFileSync('git',['-C',data+'/repo','rev-parse','HEAD'],{encoding:'utf8'}).trim());
|
||||
const db=new DatabaseSync(data+'/td.sqlite');
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
let updates=0;
|
||||
try {
|
||||
for(const table of db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) {
|
||||
const cols=db.prepare(`PRAGMA table_info("${table.name}")`).all().filter(c=>c.name.includes('commit'));
|
||||
for(const col of cols)for(const [old,next] of map){if(old===next)continue;if(/^0+$/.test(next))continue;updates+=Number(db.prepare(`UPDATE "${table.name}" SET "${col.name}"=? WHERE "${col.name}"=?`).run(next,old).changes);}
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch(e){db.exec('ROLLBACK');throw e;}
|
||||
db.close();
|
||||
const admin=JSON.parse(fs.readFileSync(data+'/forgejo-admin.json','utf8'));
|
||||
async function api(method,path,body){const r=await fetch('http://forgejo:3000/api/v1'+path,{method,headers:{Authorization:'Basic '+Buffer.from(admin.username+':'+admin.password).toString('base64'),'content-type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error(method+' '+path+' '+r.status+' '+await r.text());return r.status===204?null:r.json();}
|
||||
const route='/repos/clank/rodecomp/branch_protections';
|
||||
const protections=await api('GET',route);
|
||||
const main=protections.find(p=>p.rule_name==='main');
|
||||
if(!main)throw Error('Expected main protection');
|
||||
await api('DELETE',route+'/main');
|
||||
try {execFileSync('git',['-C',data+'/repo','push','--force-with-lease=refs/heads/main:41ea356f358ba643231157a498fff099f077f9d6','forgejo','HEAD:refs/heads/main'],{stdio:'pipe'});}
|
||||
finally {await api('POST',route,{rule_name:'main',enable_push:true,enable_push_whitelist:true,push_whitelist_usernames:['clank-bot'],block_on_rejected_reviews:false});}
|
||||
console.log('Updated commit references:',updates,'; rewritten main published; protection restored');
|
||||
12
tools/rodecomp/remove-verification.mjs
Normal file
12
tools/rodecomp/remove-verification.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import fs from 'node:fs';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
const repo='/data/repo';
|
||||
const cmake=repo+'/CMakeLists.txt';
|
||||
const text=fs.readFileSync(cmake,'utf8');
|
||||
if(!text.includes('add_subdirectory(Verification)'))throw Error('Expected Verification target');
|
||||
fs.writeFileSync(cmake,text.replace(/^add_subdirectory\(Verification\)\r?\n?/m,''));
|
||||
const file=repo+'/compile.json';const cfg=JSON.parse(fs.readFileSync(file,'utf8'));
|
||||
for(const unit of Object.keys(cfg.units))if(unit.startsWith('Verification/'))delete cfg.units[unit];
|
||||
fs.writeFileSync(file,JSON.stringify(cfg,null,2)+'\n');
|
||||
execFileSync('git',['-C',repo,'add','CMakeLists.txt','compile.json']);
|
||||
execFileSync('git',['-C',repo,'-c','user.name=Clank Together','-c','user.email=bot@clank-together.invalid','commit','-m','Remove build references to deleted verification sources'],{stdio:'inherit'});
|
||||
21
tools/rodecomp/reset-history.mjs
Normal file
21
tools/rodecomp/reset-history.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import fs from 'node:fs';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
const data='/var/lib/docker/volumes/rodecomp_data/_data',repo=data+'/repo';
|
||||
const git=(args,opts={})=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',...opts}).trim();
|
||||
if(git(['status','--porcelain']))throw Error('Canonical checkout has uncommitted changes');
|
||||
const previous=git(['rev-parse','HEAD']),tree=git(['rev-parse','HEAD^{tree}']);
|
||||
const identity={...process.env,GIT_AUTHOR_NAME:'ragnarok-clanker',GIT_AUTHOR_EMAIL:'ragnarok-clanker',GIT_COMMITTER_NAME:'ragnarok-clanker',GIT_COMMITTER_EMAIL:'ragnarok-clanker'};
|
||||
const root=git(['commit-tree',tree],{input:'Initial rodecomp source snapshot\n',env:identity});
|
||||
fs.writeFileSync(data+'/history-reset.json',JSON.stringify({previous,root,tree},null,2));
|
||||
git(['update-ref','refs/heads/main',root,previous]);
|
||||
for(const ref of git(['for-each-ref','--format=%(refname)']).split('\n'))if(ref&&ref!=='refs/heads/main')git(['update-ref','-d',ref]);
|
||||
git(['reset','--hard',root]);git(['config','user.name','ragnarok-clanker']);git(['config','user.email','ragnarok-clanker']);
|
||||
const db=new DatabaseSync(data+'/td.sqlite');db.exec('BEGIN IMMEDIATE');
|
||||
try{for(const table of db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all())for(const c of db.prepare(`PRAGMA table_info("${table.name}")`).all().filter(c=>c.name.includes('commit')))db.prepare(`UPDATE "${table.name}" SET "${c.name}"=? WHERE "${c.name}" IS NOT NULL`).run(root);db.exec('COMMIT');}catch(e){db.exec('ROLLBACK');throw e;}db.close();
|
||||
const a=JSON.parse(fs.readFileSync(data+'/forgejo-admin.json','utf8'));
|
||||
async function api(method,url,body){const r=await fetch('http://forgejo:3000/api/v1'+url,{method,headers:{Authorization:'Basic '+Buffer.from(a.username+':'+a.password).toString('base64'),'content-type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error(method+' '+r.status+' '+await r.text());}
|
||||
const route='/repos/clank/rodecomp/branch_protections';await api('DELETE',route+'/main');
|
||||
try{git(['push','--force-with-lease=refs/heads/main:'+previous,'forgejo','HEAD:refs/heads/main']);}finally{await api('POST',route,{rule_name:'main',enable_push:true,enable_push_whitelist:true,push_whitelist_usernames:['clank-bot'],block_on_rejected_reviews:false});}
|
||||
git(['reflog','expire','--expire=now','--all']);git(['gc','--prune=now']);
|
||||
console.log(JSON.stringify({previous,root,tree,commits:git(['rev-list','--count','--all'])}));
|
||||
10
tools/rodecomp/rewrite-identity.py
Normal file
10
tools/rodecomp/rewrite-identity.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import subprocess
|
||||
repo='/data/repo'
|
||||
subprocess.run(['git','-C',repo,'filter-repo','--force',
|
||||
'--blob-callback', "import re; blob.data = re.sub(rb'(?i)[a-z0-9.!#$%&*+/=?^_`{|}~-]*spl3en[a-z0-9.!#$%&*+/=?^_`{|}~-]*@[a-z0-9.-]+', b'ragnarok-clanker', blob.data); blob.data = re.sub(rb'(?i)spl3en', b'ragnarok-clanker', blob.data)",
|
||||
'--message-callback', "import re; message = re.sub(rb'(?i)[a-z0-9.!#$%&*+/=?^_`{|}~-]*spl3en[a-z0-9.!#$%&*+/=?^_`{|}~-]*@[a-z0-9.-]+', b'ragnarok-clanker', message); return re.sub(rb'(?i)spl3en', b'ragnarok-clanker', message)",
|
||||
'--name-callback', "import re; return re.sub(rb'(?i)spl3en', b'ragnarok-clanker', name)",
|
||||
'--email-callback', "return b'ragnarok-clanker' if b'spl3en' in email.lower() else email",
|
||||
'--filename-callback', "import re; return re.sub(rb'(?i)spl3en', b'ragnarok-clanker', filename)",
|
||||
'--refname-callback', "import re; return re.sub(rb'(?i)spl3en', b'ragnarok-clanker', refname)"
|
||||
],check=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue